diff options
author | Z.J. van de Weg <zegerjan@gitlab.com> | 2016-10-14 15:27:11 +0200 |
---|---|---|
committer | Z.J. van de Weg <zegerjan@gitlab.com> | 2016-10-14 15:27:11 +0200 |
commit | e505f95aff318a4ebaf150264fc59733596eb95c (patch) | |
tree | 6677be7ebea3c4382ba8e8f1edc46fb5836648cb | |
parent | 9fe57578b5da87dd7e8098d071be84e653b2b8f5 (diff) | |
parent | fd2b79b6646f9b621a5e30058a09423a8cdb6c49 (diff) | |
download | gitlab-ce-e505f95aff318a4ebaf150264fc59733596eb95c.tar.gz |
Merge branch 'master' into check-checkbox-remove-source-branch
2407 files changed, 90088 insertions, 24280 deletions
diff --git a/.csscomb.json b/.csscomb.json index 741cc1488b5..aa6a17f7517 100644 --- a/.csscomb.json +++ b/.csscomb.json @@ -6,7 +6,7 @@ "always-semicolon": true, "color-case": "lower", "block-indent": " ", - "color-shorthand": true, + "color-shorthand": false, "element-case": "lower", "space-before-colon": "", "space-after-colon": " ", diff --git a/.flayignore b/.flayignore index 9c9875d4f9e..44df2ba2371 100644 --- a/.flayignore +++ b/.flayignore @@ -1 +1,3 @@ *.erb +lib/gitlab/sanitizers/svg/whitelist.rb +lib/gitlab/diff/position_tracer.rb diff --git a/.gitignore b/.gitignore index ce6a363fe35..9166512606d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ /config/secrets.yml /config/sidekiq.yml /coverage/* +/coverage-javascript/ /db/*.sqlite3 /db/*.sqlite3-journal /db/data.yml @@ -47,3 +48,4 @@ /vendor/bundle/* /builds/* /shared/* +/.gitlab_workhorse_secret diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8da9acf9066..c3b864f16cf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,8 @@ -image: "ruby:2.1" +image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3-git-2.7-phantomjs-2.1" cache: - key: "ruby21" + key: "ruby-231" paths: - - vendor/apt - vendor/ruby variables: @@ -12,9 +11,10 @@ variables: RSPEC_RETRY_RETRY_COUNT: "3" RAILS_ENV: "test" SIMPLECOV: "true" - USE_DB: "true" + SETUP_DB: "true" USE_BUNDLE_INSTALL: "true" GIT_DEPTH: "20" + PHANTOMJS_VERSION: "2.1.1" before_script: - source ./scripts/prepare_build.sh @@ -22,7 +22,7 @@ before_script: - bundle --version - '[ "$USE_BUNDLE_INSTALL" != "true" ] || retry bundle install --without postgres production --jobs $(nproc) "${FLAGS[@]}"' - retry gem install knapsack - - '[ "$USE_DB" != "true" ] || bundle exec rake db:drop db:create db:schema:load db:migrate' + - '[ "$SETUP_DB" != "true" ] || bundle exec rake db:drop db:create db:schema:load db:migrate' stages: - prepare @@ -34,7 +34,7 @@ stages: .knapsack-state: &knapsack-state services: [] variables: - USE_DB: "false" + SETUP_DB: "false" USE_BUNDLE_INSTALL: "false" cache: key: "knapsack" @@ -81,7 +81,7 @@ update-knapsack: - export KNAPSACK_REPORT_PATH=knapsack/rspec_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_GENERATE_REPORT=true - cp knapsack/rspec_report.json ${KNAPSACK_REPORT_PATH} - - knapsack rspec + - knapsack rspec "--color --format documentation" artifacts: expire_in: 31d paths: @@ -138,64 +138,63 @@ spinach 7 10: *spinach-knapsack spinach 8 10: *spinach-knapsack spinach 9 10: *spinach-knapsack -# Execute all testing suites against Ruby 2.3 -.ruby-23: &ruby-23 - image: "ruby:2.3" +# Execute all testing suites against Ruby 2.1 +.ruby-21: &ruby-21 + image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.1-git-2.7-phantomjs-2.1" <<: *use-db only: - master cache: - key: "ruby-23" + key: "ruby21" paths: - - vendor/apt - vendor/ruby -.rspec-knapsack-ruby23: &rspec-knapsack-ruby23 +.rspec-knapsack-ruby21: &rspec-knapsack-ruby21 <<: *rspec-knapsack - <<: *ruby-23 + <<: *ruby-21 -.spinach-knapsack-ruby23: &spinach-knapsack-ruby23 +.spinach-knapsack-ruby21: &spinach-knapsack-ruby21 <<: *spinach-knapsack - <<: *ruby-23 - -rspec 0 20 ruby23: *rspec-knapsack-ruby23 -rspec 1 20 ruby23: *rspec-knapsack-ruby23 -rspec 2 20 ruby23: *rspec-knapsack-ruby23 -rspec 3 20 ruby23: *rspec-knapsack-ruby23 -rspec 4 20 ruby23: *rspec-knapsack-ruby23 -rspec 5 20 ruby23: *rspec-knapsack-ruby23 -rspec 6 20 ruby23: *rspec-knapsack-ruby23 -rspec 7 20 ruby23: *rspec-knapsack-ruby23 -rspec 8 20 ruby23: *rspec-knapsack-ruby23 -rspec 9 20 ruby23: *rspec-knapsack-ruby23 -rspec 10 20 ruby23: *rspec-knapsack-ruby23 -rspec 11 20 ruby23: *rspec-knapsack-ruby23 -rspec 12 20 ruby23: *rspec-knapsack-ruby23 -rspec 13 20 ruby23: *rspec-knapsack-ruby23 -rspec 14 20 ruby23: *rspec-knapsack-ruby23 -rspec 15 20 ruby23: *rspec-knapsack-ruby23 -rspec 16 20 ruby23: *rspec-knapsack-ruby23 -rspec 17 20 ruby23: *rspec-knapsack-ruby23 -rspec 18 20 ruby23: *rspec-knapsack-ruby23 -rspec 19 20 ruby23: *rspec-knapsack-ruby23 - -spinach 0 10 ruby23: *spinach-knapsack-ruby23 -spinach 1 10 ruby23: *spinach-knapsack-ruby23 -spinach 2 10 ruby23: *spinach-knapsack-ruby23 -spinach 3 10 ruby23: *spinach-knapsack-ruby23 -spinach 4 10 ruby23: *spinach-knapsack-ruby23 -spinach 5 10 ruby23: *spinach-knapsack-ruby23 -spinach 6 10 ruby23: *spinach-knapsack-ruby23 -spinach 7 10 ruby23: *spinach-knapsack-ruby23 -spinach 8 10 ruby23: *spinach-knapsack-ruby23 -spinach 9 10 ruby23: *spinach-knapsack-ruby23 + <<: *ruby-21 + +rspec 0 20 ruby21: *rspec-knapsack-ruby21 +rspec 1 20 ruby21: *rspec-knapsack-ruby21 +rspec 2 20 ruby21: *rspec-knapsack-ruby21 +rspec 3 20 ruby21: *rspec-knapsack-ruby21 +rspec 4 20 ruby21: *rspec-knapsack-ruby21 +rspec 5 20 ruby21: *rspec-knapsack-ruby21 +rspec 6 20 ruby21: *rspec-knapsack-ruby21 +rspec 7 20 ruby21: *rspec-knapsack-ruby21 +rspec 8 20 ruby21: *rspec-knapsack-ruby21 +rspec 9 20 ruby21: *rspec-knapsack-ruby21 +rspec 10 20 ruby21: *rspec-knapsack-ruby21 +rspec 11 20 ruby21: *rspec-knapsack-ruby21 +rspec 12 20 ruby21: *rspec-knapsack-ruby21 +rspec 13 20 ruby21: *rspec-knapsack-ruby21 +rspec 14 20 ruby21: *rspec-knapsack-ruby21 +rspec 15 20 ruby21: *rspec-knapsack-ruby21 +rspec 16 20 ruby21: *rspec-knapsack-ruby21 +rspec 17 20 ruby21: *rspec-knapsack-ruby21 +rspec 18 20 ruby21: *rspec-knapsack-ruby21 +rspec 19 20 ruby21: *rspec-knapsack-ruby21 + +spinach 0 10 ruby21: *spinach-knapsack-ruby21 +spinach 1 10 ruby21: *spinach-knapsack-ruby21 +spinach 2 10 ruby21: *spinach-knapsack-ruby21 +spinach 3 10 ruby21: *spinach-knapsack-ruby21 +spinach 4 10 ruby21: *spinach-knapsack-ruby21 +spinach 5 10 ruby21: *spinach-knapsack-ruby21 +spinach 6 10 ruby21: *spinach-knapsack-ruby21 +spinach 7 10 ruby21: *spinach-knapsack-ruby21 +spinach 8 10 ruby21: *spinach-knapsack-ruby21 +spinach 9 10 ruby21: *spinach-knapsack-ruby21 # Other generic tests .ruby-static-analysis: &ruby-static-analysis variables: SIMPLECOV: "false" - USE_DB: "false" + SETUP_DB: "false" USE_BUNDLE_INSTALL: "true" .exec: &exec @@ -205,12 +204,19 @@ spinach 9 10 ruby23: *spinach-knapsack-ruby23 - bundle exec $CI_BUILD_NAME rubocop: *exec +rake haml_lint: *exec rake scss_lint: *exec rake brakeman: *exec -rake flog: *exec rake flay: *exec license_finder: *exec rake downtime_check: *exec +rake ce_to_ee_merge_check: + <<: *exec + only: + - branches + except: + - tags + allow_failure: yes rake db:migrate:reset: stage: test @@ -218,11 +224,49 @@ rake db:migrate:reset: script: - rake db:migrate:reset +rake db:seed_fu: + stage: test + <<: *use-db + variables: + SIZE: "1" + SETUP_DB: "false" + RAILS_ENV: "development" + script: + - git clone https://gitlab.com/gitlab-org/gitlab-test.git + /home/git/repositories/gitlab-org/gitlab-test.git + - bundle exec rake db:setup db:seed_fu + artifacts: + when: on_failure + expire_in: 1d + paths: + - log/development.log + teaspoon: stage: test <<: *use-db script: + - curl --silent --location https://deb.nodesource.com/setup_6.x | bash - + - apt-get install --assume-yes nodejs + - npm install --global istanbul - teaspoon + artifacts: + name: coverage-javascript + expire_in: 31d + paths: + - coverage-javascript/default/ + +lint-doc: + stage: test + image: "phusion/baseimage:latest" + before_script: [] + script: + - scripts/lint-doc.sh + +bundler:check: + stage: test + <<: *ruby-static-analysis + script: + - bundle check bundler:audit: stage: test @@ -232,11 +276,26 @@ bundler:audit: script: - "bundle exec bundle-audit check --update --ignore OSVDB-115941" +migration paths: + stage: test + <<: *use-db + only: + - master@gitlab-org/gitlab-ce + script: + - git checkout HEAD . + - git fetch --tags + - git checkout v8.5.9 + - 'echo test: unix:/var/opt/gitlab/redis/redis.socket > config/resque.yml' + - bundle install --without postgres production --jobs $(nproc) "${FLAGS[@]}" --retry=3 + - rake db:drop db:create db:schema:load db:seed_fu + - git checkout $CI_BUILD_REF + - rake db:migrate + coverage: stage: post-test services: [] variables: - USE_DB: "false" + SETUP_DB: "false" USE_BUNDLE_INSTALL: "true" script: - bundle exec scripts/merge-simplecov @@ -247,13 +306,23 @@ coverage: - coverage/index.html - coverage/assets/ +# Trigger docs build +trigger_docs: + stage: post-test + before_script: [] + cache: {} + artifacts: {} + script: + - "curl -X POST -F token=${DOCS_TRIGGER_TOKEN} -F ref=master https://gitlab.com/api/v3/projects/38069/trigger/builds" + only: + - master # Notify slack in the end notify:slack: stage: post-test variables: - USE_DB: "false" + SETUP_DB: "false" USE_BUNDLE_INSTALL: "false" script: - ./scripts/notify_slack.sh "#builds" "Build on \`$CI_BUILD_REF_NAME\` failed! Commit \`$(git log -1 --oneline)\` See <https://gitlab.com/gitlab-org/$(basename "$PWD")/commit/"$CI_BUILD_REF"/builds>" @@ -269,12 +338,27 @@ pages: stage: pages dependencies: - coverage + - teaspoon script: - mv public/ .public/ - mkdir public/ - mv coverage public/coverage-ruby + - mv coverage-javascript/default/ public/coverage-javascript/ artifacts: paths: - public only: - master + +# Insurance in case a gem needed by one of our releases gets yanked from +# rubygems.org in the future. +cache gems: + only: + - tags + variables: + SETUP_DB: "false" + script: + - bundle package --all --all-platforms + artifacts: + paths: + - vendor/cache diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 00000000000..ac38f0c9521 --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,44 @@ +### Summary + +(Summarize the bug encountered concisely) + +### Steps to reproduce + +(How one can reproduce the issue - this is very important) + +### Expected behavior + +(What you should see instead) + +### Actual behavior + +(What actually happens) + +### Relevant logs and/or screenshots + +(Paste any relevant logs - please use code blocks (```) to format console output, +logs, and code as it's very hard to read otherwise.) + +### Output of checks + +#### Results of GitLab application Check + +(For installations with omnibus-gitlab package run and paste the output of: +`sudo gitlab-rake gitlab:check SANITIZE=true`) + +(For installations from source run and paste the output of: +`sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production SANITIZE=true`) + +(we will only investigate if the tests are passing) + +#### Results of GitLab environment info + +(For installations with omnibus-gitlab package run and paste the output of: +`sudo gitlab-rake gitlab:env:info`) + +(For installations from source run and paste the output of: +`sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production`) + +### Possible fixes + +(If you can, link to the line of code that might be responsible for the problem) diff --git a/.gitlab/issue_templates/Feature Proposal.md b/.gitlab/issue_templates/Feature Proposal.md new file mode 100644 index 00000000000..ea895ee6275 --- /dev/null +++ b/.gitlab/issue_templates/Feature Proposal.md @@ -0,0 +1,7 @@ +### Description + +(Include problem, use cases, benefits, and/or goals) + +### Proposal + +### Links / references diff --git a/.gitlab/merge_request_templates/Documentation.md b/.gitlab/merge_request_templates/Documentation.md new file mode 100644 index 00000000000..d2a1eb56423 --- /dev/null +++ b/.gitlab/merge_request_templates/Documentation.md @@ -0,0 +1,14 @@ +See the general Documentation guidelines http://docs.gitlab.com/ce/development/doc_styleguide.html. + +## What does this MR do? + +(briefly describe what this MR is about) + +## Moving docs to a new location? + +See the guidelines: http://docs.gitlab.com/ce/development/doc_styleguide.html#changing-document-location + +- [ ] Make sure the old link is not removed and has its contents replaced with a link to the new location. +- [ ] Make sure internal links pointing to the document in question are not broken. +- [ ] Search and replace any links referring to old docs in GitLab Rails app, specifically under the `app/views/` directory. +- [ ] If working on CE, submit an MR to EE with the changes as well. diff --git a/.haml-lint.yml b/.haml-lint.yml new file mode 100644 index 00000000000..da9a43d9c6d --- /dev/null +++ b/.haml-lint.yml @@ -0,0 +1,103 @@ +# Whether to ignore frontmatter at the beginning of HAML documents for +# frameworks such as Jekyll/Middleman +skip_frontmatter: false +exclude: + - 'vendor/**/*' + - 'spec/**/*' + +linters: + AltText: + enabled: false + + ClassAttributeWithStaticValue: + enabled: false + + ClassesBeforeIds: + enabled: false + + ConsecutiveComments: + enabled: false + + ConsecutiveSilentScripts: + enabled: false + max_consecutive: 2 + + EmptyObjectReference: + enabled: true + + EmptyScript: + enabled: true + + FinalNewline: + enabled: false + present: true + + HtmlAttributes: + enabled: false + + ImplicitDiv: + enabled: false + + LeadingCommentSpace: + enabled: false + + LineLength: + enabled: false + max: 80 + + MultilinePipe: + enabled: false + + MultilineScript: + enabled: true + + ObjectReferenceAttributes: + enabled: true + + RuboCop: + enabled: false + # These cops are incredibly noisy when it comes to HAML templates, so we + # ignore them. + ignored_cops: + - Lint/BlockAlignment + - Lint/EndAlignment + - Lint/Void + - Metrics/LineLength + - Style/AlignParameters + - Style/BlockNesting + - Style/ElseAlignment + - Style/FileName + - Style/FinalNewline + - Style/FrozenStringLiteralComment + - Style/IfUnlessModifier + - Style/IndentationWidth + - Style/Next + - Style/TrailingBlankLines + - Style/TrailingWhitespace + - Style/WhileUntilModifier + + RubyComments: + enabled: false + + SpaceBeforeScript: + enabled: false + + SpaceInsideHashAttributes: + enabled: false + style: space + + Indentation: + enabled: true + character: space # or tab + + TagName: + enabled: true + + TrailingWhitespace: + enabled: false + + UnnecessaryInterpolation: + enabled: false + + UnnecessaryStringOutput: + enabled: false diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000000..bd5ac22132c --- /dev/null +++ b/.mailmap @@ -0,0 +1,35 @@ +# +# This list is used by git-shortlog to make contributions from the +# same person appearing to be so. +# + +Achilleas Pipinellis <axilleas@axilleas.me> <axilleas@archlinux.gr> +Achilleas Pipinellis <axilleas@axilleas.me> <axilleas@users.noreply.github.com> +Dmitriy Zaporozhets <dzaporozhets@gitlab.com> <dmitriy.zaporozhets@gmail.com> +Dmitriy Zaporozhets <dzaporozhets@gitlab.com> <dzaporozhets@sphereconsultinginc.com> +Douwe Maan <douwe@gitlab.com> <douwe@selenight.nl> +Douwe Maan <douwe@gitlab.com> <me@douwe.me> +Grzegorz Bizon <grzegorz@gitlab.com> <grzegorz.bizon@ntsn.pl> +Grzegorz Bizon <grzegorz@gitlab.com> <grzesiek.bizon@gmail.com> +Jacob Vosmaer <jacob@gitlab.com> <contact@jacobvosmaer.nl> +Jacob Vosmaer <jacob@gitlab.com> Jacob Vosmaer (GitLab) <jacob@gitlab.com> +Jacob Schatz <jschatz@gitlab.com> <jacobschatz@Jacobs-MacBook-Pro.local> +Jacob Schatz <jschatz@gitlab.com> <jacobschatz@Jacobs-MBP.fios-router.home> +Jacob Schatz <jschatz@gitlab.com> <jschatz1@gmail.com> +James Lopez <james@jameslopez.es> <james@gitlab.com> +James Lopez <james@jameslopez.es> <james.lopez@vodafone.com> +Kamil Trzciński <kamil@gitlab.com> <ayufan@ayufan.eu> +Marin Jankovski <maxlazio@gmail.com> <marin@gitlab.com> +Phil Hughes <me@iamphill.com> <theephil@gmail.com> +Rémy Coutable <remy@rymai.me> <remy@gitlab.com> +Robert Schilling <rschilling@student.tugraz.at> <Razer6@users.noreply.github.com> +Robert Schilling <rschilling@student.tugraz.at> <schilling.ro@gmail.com> +Robert Speicher <robert@gitlab.com> <rspeicher@gmail.com> +Stan Hu <stanhu@gmail.com> <stanhu@alum.mit.edu> +Stan Hu <stanhu@gmail.com> <stanhu@packetzoom.com> +Stan Hu <stanhu@gmail.com> <stanhu@users.noreply.github.com> +Stan Hu <stanhu@gmail.com> stanhu <stanhu@gmail.com> +Sytse Sijbrandij <sytse@gitlab.com> <sytse+admin@gitlab.com> +Sytse Sijbrandij <sytse@gitlab.com> <sytse@dosire.com> +Sytse Sijbrandij <sytse@gitlab.com> <sytses@gmail.com> +Sytse Sijbrandij <sytse@gitlab.com> dosire <sytse@gitlab.com> diff --git a/.pkgr.yml b/.pkgr.yml index 8fc9fddf8f7..10bcd7bd4bd 100644 --- a/.pkgr.yml +++ b/.pkgr.yml @@ -3,6 +3,8 @@ group: git services: - postgres before_precompile: ./bin/pkgr_before_precompile.sh +env: + - SKIP_STORAGE_VALIDATION=true targets: debian-7: &wheezy build_dependencies: @@ -25,6 +27,16 @@ targets: - libicu52 - libpcre3 - git + ubuntu-16.04: + build_dependencies: + - libkrb5-dev + - libicu-dev + - cmake + - pkg-config + dependencies: + - libicu55 + - libpcre3 + - git centos-6: build_dependencies: - krb5-devel diff --git a/.rubocop.yml b/.rubocop.yml index 556a5d11a39..bec2464c740 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,8 +5,8 @@ require: inherit_from: .rubocop_todo.yml AllCops: - TargetRubyVersion: 2.1 - # Cop names are not displayed in offense messages by default. Change behavior + TargetRubyVersion: 2.3 + # Cop names are not d§splayed in offense messages by default. Change behavior # by overriding DisplayCopNames, or by giving the -D/--display-cop-names # option. DisplayCopNames: true @@ -149,19 +149,19 @@ Style/EmptyLinesAroundAccessModifier: # Keeps track of empty lines around block bodies. Style/EmptyLinesAroundBlockBody: - Enabled: false + Enabled: true # Keeps track of empty lines around class bodies. Style/EmptyLinesAroundClassBody: - Enabled: false + Enabled: true # Keeps track of empty lines around module bodies. Style/EmptyLinesAroundModuleBody: - Enabled: false + Enabled: true # Keeps track of empty lines around method bodies. Style/EmptyLinesAroundMethodBody: - Enabled: false + Enabled: true # Avoid the use of END blocks. Style/EndBlock: @@ -192,6 +192,9 @@ Style/FlipFlop: Style/For: Enabled: true +# Checks if there is a magic comment to enforce string literals +Style/FrozenStringLiteralComment: + Enabled: false # Do not introduce global variables. Style/GlobalVars: Enabled: true @@ -373,6 +376,10 @@ Style/SpaceAfterNot: Style/SpaceAfterSemicolon: Enabled: true +# Use space around equals in parameter default +Style/SpaceAroundEqualsInParameterDefault: + Enabled: true + # Use a space around keywords if appropriate. Style/SpaceAroundKeyword: Enabled: true @@ -446,6 +453,10 @@ Style/VariableName: EnforcedStyle: snake_case Enabled: true +# Use the configured style when numbering variables. +Style/VariableNumber: + Enabled: false + # Use when x then ... for one-line cases. Style/WhenThen: Enabled: true @@ -632,6 +643,10 @@ Lint/RescueException: Lint/ShadowedException: Enabled: false +# Checks for Object#to_s usage in string interpolation. +Lint/StringConversionInInterpolation: + Enabled: true + # Do not use prefix `_` for a variable that is used. Lint/UnderscorePrefixedVariableName: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 76ae5952753..11b34fafa2a 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,21 +1,21 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 0` -# on 2016-07-13 12:36:08 -0600 using RuboCop version 0.41.2. +# on 2016-10-04 13:16:20 +0200 using RuboCop version 0.43.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: 154 +# Offense count: 160 Lint/AmbiguousRegexpLiteral: Enabled: false -# Offense count: 43 +# Offense count: 40 # Configuration parameters: AllowSafeAssignment. Lint/AssignmentInCondition: Enabled: false -# Offense count: 14 +# Offense count: 18 Lint/HandleExceptions: Enabled: false @@ -23,117 +23,176 @@ Lint/HandleExceptions: Lint/Loop: Enabled: false -# Offense count: 15 +# Offense count: 19 Lint/ShadowingOuterLocalVariable: Enabled: false -# Offense count: 3 +# Offense count: 9 +# Cop supports --auto-correct. +Lint/UnifiedInteger: + Enabled: false + +# Offense count: 13 # Cop supports --auto-correct. -Lint/StringConversionInInterpolation: +Lint/UnneededSplatExpansion: Enabled: false -# Offense count: 44 +# Offense count: 69 # Cop supports --auto-correct. # Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. Lint/UnusedBlockArgument: Enabled: false -# Offense count: 129 +# Offense count: 144 # Cop supports --auto-correct. # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. Lint/UnusedMethodArgument: Enabled: false -# Offense count: 12 -# Cop supports --auto-correct. -Performance/PushSplat: - Enabled: false - # Offense count: 2 # Cop supports --auto-correct. Performance/RedundantBlockCall: Enabled: false -# Offense count: 4 +# Offense count: 5 # Cop supports --auto-correct. Performance/RedundantMatch: Enabled: false -# Offense count: 24 +# Offense count: 26 # Cop supports --auto-correct. # Configuration parameters: MaxKeyValuePairs. Performance/RedundantMerge: Enabled: false -# Offense count: 60 +# Offense count: 7 +RSpec/BeEql: + Enabled: false + +# Offense count: 20 +# Configuration parameters: CustomIncludeMethods. +RSpec/EmptyExampleGroup: + Enabled: false + +# Offense count: 16 +RSpec/ExpectActual: + Enabled: false + +# Offense count: 34 +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: implicit, each, example +RSpec/HookArgument: + Enabled: false + +# Offense count: 168 +RSpec/LeadingSubject: + Enabled: false + +# Offense count: 162 +RSpec/LetSetup: + Enabled: false + +# Offense count: 10 +RSpec/MessageChain: + Enabled: false + +# Offense count: 714 +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: allow, expect +RSpec/MessageExpectation: + Enabled: false + +# Offense count: 2423 +RSpec/MultipleExpectations: + Max: 36 + +# Offense count: 1504 +RSpec/NamedSubject: + Enabled: false + +# Offense count: 1335 +# Configuration parameters: MaxNesting. +RSpec/NestedGroups: + Enabled: false + +# Offense count: 99 +RSpec/SubjectStub: + Enabled: false + +# Offense count: 64 Rails/OutputSafety: Enabled: false -# Offense count: 128 +# Offense count: 151 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: strict, flexible Rails/TimeZone: Enabled: false -# Offense count: 12 +# Offense count: 15 # Cop supports --auto-correct. # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/Validation: Enabled: false -# Offense count: 217 +# Offense count: 2 +# Cop supports --auto-correct. +Security/JSONLoad: + Enabled: false + +# Offense count: 284 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: with_first_parameter, with_fixed_indentation Style/AlignParameters: Enabled: false -# Offense count: 32 +# Offense count: 28 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: always, conditionals Style/AndOr: Enabled: false -# Offense count: 47 +# Offense count: 52 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: percent_q, bare_percent Style/BarePercentLiterals: Enabled: false -# Offense count: 258 +# Offense count: 291 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: braces, no_braces, context_dependent Style/BracesAroundHashParameters: Enabled: false -# Offense count: 5 +# Offense count: 6 Style/CaseEquality: Enabled: false -# Offense count: 19 +# Offense count: 26 # Cop supports --auto-correct. Style/ColonMethodCall: Enabled: false -# Offense count: 3 +# Offense count: 2 # Cop supports --auto-correct. # Configuration parameters: Keywords. # Keywords: TODO, FIXME, OPTIMIZE, HACK, REVIEW Style/CommentAnnotation: Enabled: false -# Offense count: 34 +# Offense count: 30 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, SingleLineConditionsOnly. # SupportedStyles: assign_to_condition, assign_inside_condition Style/ConditionalAssignment: Enabled: false -# Offense count: 789 +# Offense count: 957 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: leading, trailing @@ -144,11 +203,12 @@ Style/DotPosition: Style/DoubleNegation: Enabled: false -# Offense count: 3 +# Offense count: 6 +# Cop supports --auto-correct. Style/EachWithObject: Enabled: false -# Offense count: 30 +# Offense count: 26 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: empty, nil, both @@ -160,19 +220,19 @@ Style/EmptyElse: Style/EmptyLiteral: Enabled: false -# Offense count: 123 +# Offense count: 140 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment, ForceEqualSignAlignment. Style/ExtraSpacing: Enabled: false -# Offense count: 7 +# Offense count: 6 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: format, sprintf, percent Style/FormatString: Enabled: false -# Offense count: 48 +# Offense count: 201 # Configuration parameters: MinBodyLength. Style/GuardClause: Enabled: false @@ -181,73 +241,84 @@ Style/GuardClause: Style/IfInsideElse: Enabled: false -# Offense count: 177 +# Offense count: 174 # Cop supports --auto-correct. # Configuration parameters: MaxLineLength. Style/IfUnlessModifier: Enabled: false -# Offense count: 52 +# Offense count: 53 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_brackets Style/IndentArray: Enabled: false -# Offense count: 89 +# Offense count: 95 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_braces Style/IndentHash: Enabled: false -# Offense count: 12 +# Offense count: 29 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: line_count_dependent, lambda, literal Style/Lambda: Enabled: false -# Offense count: 6 +# Offense count: 5 # Cop supports --auto-correct. Style/LineEndConcatenation: Enabled: false -# Offense count: 13 +# Offense count: 15 # Cop supports --auto-correct. Style/MethodCallParentheses: Enabled: false -# Offense count: 62 +# Offense count: 8 +Style/MethodMissing: + Enabled: false + +# Offense count: 95 # Cop supports --auto-correct. Style/MutableConstant: Enabled: false -# Offense count: 10 +# Offense count: 8 # Cop supports --auto-correct. Style/NestedParenthesizedCalls: Enabled: false -# Offense count: 12 +# Offense count: 13 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles. # SupportedStyles: skip_modifier_ifs, always Style/Next: Enabled: false -# Offense count: 8 +# Offense count: 12 # Cop supports --auto-correct. # Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles. # SupportedOctalStyles: zero_with_o, zero_only Style/NumericLiteralPrefix: Enabled: false +# Offense count: 53 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Enabled: false + # Offense count: 29 # Cop supports --auto-correct. Style/ParallelAssignment: Enabled: false -# Offense count: 208 +# Offense count: 294 # Cop supports --auto-correct. # Configuration parameters: PreferredDelimiters. Style/PercentLiteralDelimiters: @@ -265,7 +336,7 @@ Style/PercentQLiterals: Style/PerlBackrefs: Enabled: false -# Offense count: 32 +# Offense count: 38 # Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. # NamePrefix: is_, has_, have_ # NamePrefixBlacklist: is_, has_, have_ @@ -273,7 +344,7 @@ Style/PerlBackrefs: Style/PredicateName: Enabled: false -# Offense count: 28 +# Offense count: 26 # Cop supports --auto-correct. Style/PreferredHashMethods: Enabled: false @@ -283,14 +354,14 @@ Style/PreferredHashMethods: Style/Proc: Enabled: false -# Offense count: 20 +# Offense count: 22 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: compact, exploded Style/RaiseArgs: Enabled: false -# Offense count: 3 +# Offense count: 4 # Cop supports --auto-correct. Style/RedundantBegin: Enabled: false @@ -300,29 +371,34 @@ Style/RedundantBegin: Style/RedundantException: Enabled: false -# Offense count: 23 +# Offense count: 24 # Cop supports --auto-correct. Style/RedundantFreeze: Enabled: false -# Offense count: 377 +# Offense count: 427 # Cop supports --auto-correct. Style/RedundantSelf: Enabled: false -# Offense count: 94 +# Offense count: 97 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes. # SupportedStyles: slashes, percent_r, mixed Style/RegexpLiteral: Enabled: false -# Offense count: 17 +# Offense count: 18 # Cop supports --auto-correct. Style/RescueModifier: Enabled: false -# Offense count: 2 +# Offense count: 114 +# Cop supports --auto-correct. +Style/SafeNavigation: + Enabled: false + +# Offense count: 7 # Cop supports --auto-correct. Style/SelfAssignment: Enabled: false @@ -339,77 +415,77 @@ Style/SingleLineBlockParams: Style/SingleLineMethods: Enabled: false -# Offense count: 14 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: space, no_space -Style/SpaceAroundEqualsInParameterDefault: - Enabled: false - -# Offense count: 119 +# Offense count: 125 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: space, no_space Style/SpaceBeforeBlockBraces: Enabled: false -# Offense count: 11 +# Offense count: 10 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment. Style/SpaceBeforeFirstArg: Enabled: false -# Offense count: 130 +# Offense count: 145 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space Style/SpaceInsideBlockBraces: Enabled: false -# Offense count: 98 +# Offense count: 99 # Cop supports --auto-correct. Style/SpaceInsideBrackets: Enabled: false -# Offense count: 60 +# Offense count: 65 # Cop supports --auto-correct. Style/SpaceInsideParens: Enabled: false -# Offense count: 5 +# Offense count: 7 # Cop supports --auto-correct. Style/SpaceInsidePercentLiteralDelimiters: Enabled: false -# Offense count: 36 +# Offense count: 41 # Cop supports --auto-correct. # Configuration parameters: SupportedStyles. # SupportedStyles: use_perl_names, use_english_names Style/SpecialGlobalVars: EnforcedStyle: use_perl_names -# Offense count: 30 +# Offense count: 31 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: single_quotes, double_quotes Style/StringLiteralsInInterpolation: Enabled: false -# Offense count: 24 +# Offense count: 33 # Cop supports --auto-correct. # Configuration parameters: IgnoredMethods. # IgnoredMethods: respond_to, define_method Style/SymbolProc: Enabled: false -# Offense count: 23 +# Offense count: 5 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment. +# SupportedStyles: require_parentheses, require_no_parentheses +Style/TernaryParentheses: + Enabled: false + +# Offense count: 29 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyleForMultiline, SupportedStyles. # SupportedStyles: comma, consistent_comma, no_comma Style/TrailingCommaInArguments: Enabled: false -# Offense count: 113 +# Offense count: 102 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyleForMultiline, SupportedStyles. # SupportedStyles: comma, consistent_comma, no_comma @@ -422,7 +498,7 @@ Style/TrailingCommaInLiteral: Style/TrailingUnderscoreVariable: Enabled: false -# Offense count: 90 +# Offense count: 76 # Cop supports --auto-correct. Style/TrailingWhitespace: Enabled: false @@ -434,12 +510,12 @@ Style/TrailingWhitespace: Style/TrivialAccessors: Enabled: false -# Offense count: 3 +# Offense count: 2 # Cop supports --auto-correct. Style/UnlessElse: Enabled: false -# Offense count: 13 +# Offense count: 14 # Cop supports --auto-correct. Style/UnneededInterpolation: Enabled: false diff --git a/.ruby-version b/.ruby-version index ebf14b46981..2bf1c1ccf36 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.1.8 +2.3.1 diff --git a/.scss-lint.yml b/.scss-lint.yml index 66f9975d4ce..71df6be6a15 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -79,7 +79,7 @@ linters: # HEX colors should use three-character values where possible. HexLength: - enabled: true + enabled: false # HEX color values should use lower-case colors to differentiate between # letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`. diff --git a/CHANGELOG b/CHANGELOG index c8b8081d713..70f1c411736 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,74 +1,632 @@ Please view this file on the master branch, on stable branches it's out of date. -v 8.11.0 (unreleased) +v 8.13.0 (unreleased) + - Improve Merge When Build Succeeds triggers and execute on pipeline success. (!6675) + - Respond with 404 Not Found for non-existent tags (Linus Thiel) + - Truncate long labels with ellipsis in labels page + - Adding members no longer silently fails when there is extra whitespace + - Update runner version only when updating contacted_at + - Add link from system note to compare with previous version + - Use gitlab-shell v3.6.6 + - Add `/projects/visible` API endpoint (Ben Boeckel) + - Fix centering of custom header logos (Ashley Dumaine) + - ExpireBuildArtifactsWorker query builds table without ordering enqueuing one job per build to cleanup + - Add an example for testing a phoenix application with Gitlab CI in the docs (Manthan Mallikarjun) + - Updating verbiage on git basics to be more intuitive + - Clarify documentation for Runners API (Gennady Trafimenkov) + - Change user & group landing page routing from /u/:username to /:username + - Prevent running GfmAutocomplete setup for each diff note !6569 + - Added documentation for .gitattributes files + - AbstractReferenceFilter caches project_refs on RequestStore when active + - Replaced the check sign to arrow in the show build view. !6501 + - Add a /wip slash command to toggle the Work In Progress status of a merge request. !6259 (tbalthazar) + - Fix Error 500 when viewing old merge requests with bad diff data + - Create a new /templates namespace for the /licenses, /gitignores and /gitlab_ci_ymls API endpoints. !5717 (tbalthazar) + - Speed-up group milestones show page + - Fix inconsistent options dropdown caret on mobile viewports (ClemMakesApps) + - Extract project#update_merge_requests and SystemHooks to its own worker from GitPushService + - Don't include archived projects when creating group milestones. !4940 (Jeroen Jacobs) + - Add tag shortcut from the Commit page. !6543 + - Keep refs for each deployment + - Allow browsing branches that end with '.atom' + - Log LDAP lookup errors and don't swallow unrelated exceptions. !6103 (Markus Koller) + - Replace unique keyframes mixin with keyframe mixin with specific names (ClemMakesApps) + - Add more tests for calendar contribution (ClemMakesApps) + - Update Gitlab Shell to fix some problems with moving projects between storages + - Cache rendered markdown in the database, rather than Redis + - Avoid database queries on Banzai::ReferenceParser::BaseParser for nodes without references + - Do not alter 'force_remove_source_branch' options on MergeRequest unless specified + - Simplify Mentionable concern instance methods + - API: Ability to retrieve version information (Robert Schilling) + - Fix permission for setting an issue's due date + - API: Multi-file commit !6096 (mahcsig) + - Unicode emoji are now converted to images + - Revert "Label list shows all issues (opened or closed) with that label" + - Expose expires_at field when sharing project on API + - Fix VueJS template tags being rendered in code comments + - Added copy file path button to merge request diff files + - Fix issue with page scrolling to top when closing or pinning sidebar (lukehowell) + - Add Issue Board API support (andrebsguedes) + - Allow the Koding integration to be configured through the API + - Add new issue button to each list on Issues Board + - Added soft wrap button to repository file/blob editor + - Update namespace validation to forbid reserved names (.git and .atom) (Will Starms) + - Show the time ago a merge request was deployed to an environment + - Add word-wrap to issue title on issue and milestone boards (ClemMakesApps) + - Fix todos page mobile viewport layout (ClemMakesApps) + - Fix inconsistent highlighting of already selected activity nav-links (ClemMakesApps) + - Remove redundant mixins (ClemMakesApps) + - Added 'Download' button to the Snippets page (Justin DiPierro) + - Fix robots.txt disallowing access to groups starting with "s" (Matt Harrison) + - Close open merge request without source project (Katarzyna Kobierska Ula Budziszewska) + - Fix that manual jobs would no longer block jobs in the next stage. !6604 + - Add configurable email subject suffix (Fu Xu) + - Use defined colour for a language when available !6748 (nilsding) + - Added tooltip to fork count on project show page. (Justin DiPierro) + - Use a ConnectionPool for Rails.cache on Sidekiq servers + - Replace `alias_method_chain` with `Module#prepend` + - Enable GitLab Import/Export for non-admin users. + - Preserve label filters when sorting !6136 (Joseph Frazier) + - MergeRequest#new form load diff asynchronously + - Only update issuable labels if they have been changed + - Take filters in account in issuable counters. !6496 + - Use custom Ruby images to test builds (registry.dev.gitlab.org/gitlab/gitlab-build-images:*) + - Prevent flash alert text from being obscured when container is fluid + - Append issue template to existing description !6149 (Joseph Frazier) + - Trending projects now only show public projects and the list of projects is cached for a day + - Memoize Gitlab Shell's secret token (!6599, Justin DiPierro) + - Revoke button in Applications Settings underlines on hover. + - Use higher size on Gitlab::Redis connection pool on Sidekiq servers + - Add missing values to linter !6276 (Katarzyna Kobierska Ula Budziszewska) + - Fix Long commit messages overflow viewport in file tree + - Revert avoid touching file system on Build#artifacts? + - Stop using a Redis lease when updating the project activity timestamp whenever a new event is created + - Add disabled delete button to protected branches (ClemMakesApps) + - Add broadcast messages and alerts below sub-nav + - Better empty state for Groups view + - API: New /users/:id/events endpoint + - Update ruby-prof to 0.16.2. !6026 (Elan Ruusamäe) + - Replace bootstrap caret with fontawesome caret (ClemMakesApps) + - Fix unnecessary escaping of reserved HTML characters in milestone title. !6533 + - Add organization field to user profile + - Ignore deployment for statistics in Cycle Analytics, except in staging and production stages + - Fix enter key when navigating search site search dropdown. !6643 (Brennan Roberts) + - Fix deploy status responsiveness error !6633 + - Make searching for commits case insensitive + - Fix resolved discussion display in side-by-side diff view !6575 + - Optimize GitHub importing for speed and memory + - API: expose pipeline data in builds API (!6502, Guilherme Salazar) + - Notify the Merger about merge after successful build (Dimitris Karakasilis) + - Reorder issue and merge request titles to show IDs first. !6503 (Greg Laubenstein) + - Reduce queries needed to find users using their SSH keys when pushing commits + - Prevent rendering the link to all when the author has no access (Katarzyna Kobierska Ula Budziszewska) + - Fix broken repository 500 errors in project list + - Fix Pipeline list commit column width should be adjusted + - Close todos when accepting merge requests via the API !6486 (tonygambone) + - Ability to batch assign issues relating to a merge request to the author. !5725 (jamedjo) + - Changed Slack service user referencing from full name to username (Sebastian Poxhofer) + - Retouch environments list and deployments list + - Add multiple command support for all label related slash commands !6780 (barthc) + - Add Container Registry on/off status to Admin Area !6638 (the-undefined) + - Allow empty merge requests !6384 (Artem Sidorenko) + - Grouped pipeline dropdown is a scrollable container + - Cleanup Ci::ApplicationController. !6757 (Takuya Noguchi) + - Fixes padding in all clipboard icons that have .btn class + - Fix a typo in doc/api/labels.md + - API: all unknown routing will be handled with 404 Not Found + - Make guests unable to view MRs on private projects + +v 8.12.7 + - Use gitlab-markup gem instead of github-markup to fix `.rst` file rendering. !6659 + +v 8.12.6 + - Update mailroom to 0.8.1 in Gemfile.lock !6814 + +v 8.12.5 + - Switch from request to env in ::API::Helpers. !6615 + - Update the mail_room gem to 0.8.1 to fix a race condition with the mailbox watching thread. !6714 + - Improve issue load time performance by avoiding ORDER BY in find_by call. !6724 + - Add a new gitlab:users:clear_all_authentication_tokens task. !6745 + - Don't send Private-Token (API authentication) headers to Sentry + - Share projects via the API only with groups the authenticated user can access + +v 8.12.4 + - Fix "Copy to clipboard" tooltip to say "Copied!" when clipboard button is clicked. !6294 (lukehowell) + - Fix padding in build sidebar. !6506 + - Changed compare dropdowns to dropdowns with isolated search input. !6550 + - Fix race condition on LFS Token. !6592 + - Fix type mismatch bug when closing Jira issue. !6619 + - Fix lint-doc error. !6623 + - Skip wiki creation when GitHub project has wiki enabled. !6665 + - Fix issues importing services via Import/Export. !6667 + - Restrict failed login attempts for users with 2FA enabled. !6668 + - Fix failed project deletion when feature visibility set to private. !6688 + - Prevent claiming associated model IDs via import. + - Set GitLab project exported file permissions to owner only + - Improve the way merge request versions are compared with each other + +v 8.12.3 + - Update Gitlab Shell to support low IO priority for storage moves + +v 8.12.2 + - Fix Import/Export not recognising correctly the imported services. + - Fix snippets pagination + - Fix "Create project" button layout when visibility options are restricted + - Fix List-Unsubscribe header in emails + - Fix IssuesController#show degradation including project on loaded notes + - Fix an issue with the "Commits" section of the cycle analytics summary. !6513 + - Fix errors importing project feature and milestone models using GitLab project import + - Make JWT messages Docker-compatible + - Fix duplicate branch entry in the merge request version compare dropdown + - Respect the fork_project permission when forking projects + - Only update issuable labels if they have been changed + - Fix bug where 'Search results' repeated many times when a search in the emoji search form is cleared (Xavier Bick) (@zeiv) + - Fix resolve discussion buttons endpoint path + - Refactor remnants of CoffeeScript destructured opts and super !6261 + +v 8.12.1 + - Fix a memory leak in HTML::Pipeline::SanitizationFilter::WHITELIST + - Fix issue with search filter labels not displaying + +v 8.12.0 + - Removes inconsistency regarding tagging immediatelly as merged once you create a new branch. !6408 + - Update the rouge gem to 2.0.6, which adds highlighting support for JSX, Prometheus, and others. !6251 + - Only check :can_resolve permission if the note is resolvable + - Bump fog-aws to v0.11.0 to support ap-south-1 region + - Add ability to fork to a specific namespace using API. (ritave) + - Allow to set request_access_enabled for groups and projects + - Cleanup misalignments in Issue list view !6206 + - Only create a protected branch upon a push to a new branch if a rule for that branch doesn't exist + - Add Pipelines for Commit + - Prune events older than 12 months. (ritave) + - Prepend blank line to `Closes` message on merge request linked to issue (lukehowell) + - Fix issues/merge-request templates dropdown for forked projects + - Filter tags by name !6121 + - Update gitlab shell secret file also when it is empty. !3774 (glensc) + - Give project selection dropdowns responsive width, make non-wrapping. + - Fix note form hint showing slash commands supported for commits. + - Make push events have equal vertical spacing. + - API: Ensure invitees are not returned in Members API. + - Preserve applied filters on issues search. + - Add two-factor recovery endpoint to internal API !5510 + - Pass the "Remember me" value to the U2F authentication form + - Display stages in valid order in stages dropdown on build page + - Only update projects.last_activity_at once per hour when creating a new event + - Cycle analytics (first iteration) !5986 + - Remove vendor prefixes for linear-gradient CSS (ClemMakesApps) + - Move pushes_since_gc from the database to Redis + - Limit number of shown environments on Merge Request: show only environments for target_branch, source_branch and tags + - Add font color contrast to external label in admin area (ClemMakesApps) + - Fix find file navigation links (ClemMakesApps) + - Change logo animation to CSS (ClemMakesApps) + - Instructions for enabling Git packfile bitmaps !6104 + - Use Search::GlobalService.new in the `GET /projects/search/:query` endpoint + - Fix long comments in diffs messing with table width + - Add spec covering 'Gitlab::Git::committer_hash' !6433 (dandunckelman) + - Fix pagination on user snippets page + - Honor "fixed layout" preference in more places !6422 + - Run CI builds with the permissions of users !5735 + - Fix sorting of issues in API + - Fix download artifacts button links !6407 + - Sort project variables by key. !6275 (Diego Souza) + - Ensure specs on sorting of issues in API are deterministic on MySQL + - Added ability to use predefined CI variables for environment name + - Added ability to specify URL in environment configuration in gitlab-ci.yml + - Escape search term before passing it to Regexp.new !6241 (winniehell) + - Fix pinned sidebar behavior in smaller viewports !6169 + - Fix file permissions change when updating a file on the Gitlab UI !5979 + - Added horizontal padding on build page sidebar on code coverage block. !6196 (Vitaly Baev) + - Change merge_error column from string to text type + - Fix issue with search filter labels not displaying + - Reduce contributions calendar data payload (ClemMakesApps) + - Show all pipelines for merge requests even from discarded commits !6414 + - Replace contributions calendar timezone payload with dates (ClemMakesApps) + - Changed MR widget build status to pipeline status !6335 + - Add `web_url` field to issue, merge request, and snippet API objects (Ben Boeckel) + - Enable pipeline events by default !6278 + - Move parsing of sidekiq ps into helper !6245 (pascalbetz) + - Added go to issue boards keyboard shortcut + - Expose `sha` and `merge_commit_sha` in merge request API (Ben Boeckel) + - Emoji can be awarded on Snippets !4456 + - Set path for all JavaScript cookies to honor GitLab's subdirectory setting !5627 (Mike Greiling) + - Fix blame table layout width + - Spec testing if issue authors can read issues on private projects + - Fix bug where pagination is still displayed despite all todos marked as done (ClemMakesApps) + - Request only the LDAP attributes we need !6187 + - Center build stage columns in pipeline overview (ClemMakesApps) + - Fix bug with tooltip not hiding on discussion toggle button + - Rename behaviour to behavior in bug issue template for consistency (ClemMakesApps) + - Fix bug stopping issue description being scrollable after selecting issue template + - Remove suggested colors hover underline (ClemMakesApps) + - Fix jump to discussion button being displayed on commit notes + - Shorten task status phrase (ClemMakesApps) + - Fix project visibility level fields on settings + - Add hover color to emoji icon (ClemMakesApps) + - Increase ci_builds artifacts_size column to 8-byte integer to allow larger files + - Add textarea autoresize after comment (ClemMakesApps) + - Do not write SSH public key 'comments' to authorized_keys !6381 + - Add due date to issue todos + - Refresh todos count cache when an Issue/MR is deleted + - Fix branches page dropdown sort alignment (ClemMakesApps) + - Hides merge request button on branches page is user doesn't have permissions + - Add white background for no readme container (ClemMakesApps) + - API: Expose issue confidentiality flag. (Robert Schilling) + - Fix markdown anchor icon interaction (ClemMakesApps) + - Test migration paths from 8.5 until current release !4874 + - Replace animateEmoji timeout with eventListener (ClemMakesApps) + - Show badges in Milestone tabs. !5946 (Dan Rowden) + - Optimistic locking for Issues and Merge Requests (title and description overriding prevention) + - Require confirmation when not logged in for unsubscribe links !6223 (Maximiliano Perez Coto) + - Add `wiki_page_events` to project hook APIs (Ben Boeckel) + - Remove Gitorious import + - Loads GFM autocomplete source only when required + - Fix issue with slash commands not loading on new issue page + - Fix inconsistent background color for filter input field (ClemMakesApps) + - Remove prefixes from transition CSS property (ClemMakesApps) + - Add Sentry logging to API calls + - Add BroadcastMessage API + - Use 'git update-ref' for safer web commits !6130 + - Sort pipelines requested through the API + - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling) + - Fix issue boards loading on large screens + - Change pipeline duration to be jobs running time instead of simple wall time from start to end !6084 + - Show queued time when showing a pipeline !6084 + - Remove unused mixins (ClemMakesApps) + - Fix issue board label filtering appending already filtered labels + - Add search to all issue board lists + - Scroll active tab into view on mobile + - Fix groups sort dropdown alignment (ClemMakesApps) + - Add horizontal scrolling to all sub-navs on mobile viewports (ClemMakesApps) + - Use JavaScript tooltips for mentions !5301 (winniehell) + - Add hover state to todos !5361 (winniehell) + - Fix icon alignment of star and fork buttons !5451 (winniehell) + - Fix alignment of icon buttons !5887 (winniehell) + - Added Ubuntu 16.04 support for packager.io (JonTheNiceGuy) + - Fix markdown help references (ClemMakesApps) + - Add last commit time to repo view (ClemMakesApps) + - Fix accessibility and visibility of project list dropdown button !6140 + - Fix missing flash messages on service edit page (airatshigapov) + - Added project-specific enable/disable setting for LFS !5997 + - Added group-specific enable/disable setting for LFS !6164 + - Add optional 'author' param when making commits. !5822 (dandunckelman) + - Don't expose a user's token in the `/api/v3/user` API (!6047) + - Remove redundant js-timeago-pending from user activity log (ClemMakesApps) + - Ability to manage project issues, snippets, wiki, merge requests and builds access level + - Remove inconsistent font weight for sidebar's labels (ClemMakesApps) + - Align add button on repository view (ClemMakesApps) + - Fix contributions calendar month label truncation (ClemMakesApps) + - Import release note descriptions from GitHub (EspadaV8) + - Added tests for diff notes + - Add pipeline events to Slack integration !5525 + - Add a button to download latest successful artifacts for branches and tags !5142 + - Remove redundant pipeline tooltips (ClemMakesApps) + - Expire commit info views after one day, instead of two weeks, to allow for user email updates + - Add delimiter to project stars and forks count (ClemMakesApps) + - Fix badge count alignment (ClemMakesApps) + - Remove green outline from `New branch unavailable` button on issue page !5858 (winniehell) + - Fix repo title alignment (ClemMakesApps) + - Change update interval of contacted_at + - Add LFS support to SSH !6043 + - Fix branch title trailing space on hover (ClemMakesApps) + - Don't include 'Created By' tag line when importing from GitHub if there is a linked GitLab account (EspadaV8) + - Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison) + - Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison) + - Order award emoji tooltips in order they were added (EspadaV8) + - Fix spacing and vertical alignment on build status icon on commits page (ClemMakesApps) + - Update merge_requests.md with a simpler way to check out a merge request. !5944 + - Fix button missing type (ClemMakesApps) + - Gitlab::Checks is now instrumented + - Move to project dropdown with infinite scroll for better performance + - Fix leaking of submit buttons outside the width of a main container !18731 (originally by @pavelloz) + - Load branches asynchronously in Cherry Pick and Revert dialogs. + - Convert datetime coffeescript spec to ES6 (ClemMakesApps) + - Add merge request versions !5467 + - Change using size to use count and caching it for number of group members. !5935 + - Replace play icon font with svg (ClemMakesApps) + - Added 'only_allow_merge_if_build_succeeds' project setting in the API. !5930 (Duck) + - Reduce number of database queries on builds tab + - Wrap text in commit message containers + - Capitalize mentioned issue timeline notes (ClemMakesApps) + - Fix inconsistent checkbox alignment (ClemMakesApps) + - Use the default branch for displaying the project icon instead of master !5792 (Hannes Rosenögger) + - Adds response mime type to transaction metric action when it's not HTML + - Fix hover leading space bug in pipeline graph !5980 + - Avoid conflict with admin labels when importing GitHub labels + - User can edit closed MR with deleted fork (Katarzyna Kobierska Ula Budziszewska) !5496 + - Fix repository page ui issues + - Avoid protected branches checks when verifying access without branch name + - Add information about user and manual build start to runner as variables !6201 (Sergey Gnuskov) + - Fixed invisible scroll controls on build page on iPhone + - Fix error on raw build trace download for old builds stored in database !4822 + - Refactor the triggers page and documentation !6217 + - Show values of CI trigger variables only when clicked (Katarzyna Kobierska Ula Budziszewska) + - Use default clone protocol on "check out, review, and merge locally" help page URL + - Let the user choose a namespace and name on GitHub imports + - API for Ci Lint !5953 (Katarzyna Kobierska Urszula Budziszewska) + - Allow bulk update merge requests from merge requests index page + - Ensure validation messages are shown within the milestone form + - Add notification_settings API calls !5632 (mahcsig) + - Remove duplication between project builds and admin builds view !5680 (Katarzyna Kobierska Ula Budziszewska) + - Fix URLs with anchors in wiki !6300 (houqp) + - Deleting source project with existing fork link will close all related merge requests !6177 (Katarzyna Kobierska Ula Budziszeska) + - Return 204 instead of 404 for /ci/api/v1/builds/register.json if no builds are scheduled for a runner !6225 + - Fix Gitlab::Popen.popen thread-safety issue + - Add specs to removing project (Katarzyna Kobierska Ula Budziszewska) + - Clean environment variables when running git hooks + - Fix Import/Export issues importing protected branches and some specific models + - Fix non-master branch readme display in tree view + - Add UX improvements for merge request version diffs + +v 8.11.9 + - Don't send Private-Token (API authentication) headers to Sentry + - Share projects via the API only with groups the authenticated user can access + +v 8.11.8 + - Respect the fork_project permission when forking projects + - Set a restrictive CORS policy on the API for credentialed requests + - API: disable rails session auth for non-GET/HEAD requests + - Escape HTML nodes in builds commands in CI linter + +v 8.11.7 + - Avoid conflict with admin labels when importing GitHub labels. !6158 + - Restores `fieldName` to allow only string values in `gl_dropdown.js`. !6234 + - Allow the Rails cookie to be used for API authentication. + +v 8.11.6 + - Fix unnecessary horizontal scroll area in pipeline visualizations. !6005 + - Make merge conflict file size limit 200 KB, to match the docs. !6052 + - Fix an error where we were unable to create a CommitStatus for running state. !6107 + - Optimize discussion notes resolving and unresolving. !6141 + - Fix GitLab import button. !6167 + - Restore SSH Key title auto-population behavior. !6186 + - Fix DB schema to match latest migration. !6256 + - Exclude some pending or inactivated rows in Member scopes. + +v 8.11.5 + - Optimize branch lookups and force a repository reload for Repository#find_branch. !6087 + - Fix member expiration date picker after update. !6184 + - Fix suggested colors options for new labels in the admin area. !6138 + - Optimize discussion notes resolving and unresolving + - Fix GitLab import button + - Fix confidential issues being exposed as public using gitlab.com export + - Remove gitorious from import_sources. !6180 + - Scope webhooks/services that will run for confidential issues + - Remove gitorious from import_sources + - Fix confidential issues being exposed as public using gitlab.com export + - Use oj gem for faster JSON processing + +v 8.11.4 + - Fix resolving conflicts on forks. !6082 + - Fix diff commenting on merge requests created prior to 8.10. !6029 + - Fix pipelines tab layout regression. !5952 + - Fix "Wiki" link not appearing in navigation for projects with external wiki. !6057 + - Do not enforce using hash with hidden key in CI configuration. !6079 + - Fix hover leading space bug in pipeline graph !5980 + - Fix sorting issues by "last updated" doesn't work after import from GitHub + - GitHub importer use default project visibility for non-private projects + - Creating an issue through our API now emails label subscribers !5720 + - Block concurrent updates for Pipeline + - Don't create groups for unallowed users when importing projects + - Fix issue boards leak private label names and descriptions + - Fix broken gitlab:backup:restore because of bad permissions on repo storage !6098 (Dirk Hörner) + - Remove gitorious. !5866 + - Allow compare merge request versions + +v 8.11.3 + - Allow system info page to handle case where info is unavailable + - Label list shows all issues (opened or closed) with that label + - Don't show resolve conflicts link before MR status is updated + - Fix IE11 fork button bug !5982 + - Don't prevent viewing the MR when git refs for conflicts can't be found on disk + - Fix external issue tracker "Issues" link leading to 404s + - Don't try to show merge conflict resolution info if a merge conflict contains non-UTF-8 characters + - Automatically expand hidden discussions when accessed by a permalink !5585 (Mike Greiling) + - Issues filters reset button + +v 8.11.2 + - Show "Create Merge Request" widget for push events to fork projects on the source project. !5978 + - Use gitlab-workhorse 0.7.11 !5983 + - Does not halt the GitHub import process when an error occurs. !5763 + - Fix file links on project page when default view is Files !5933 + - Fixed enter key in search input not working !5888 + +v 8.11.1 + - Pulled due to packaging error. + +v 8.11.0 + - Use test coverage value from the latest successful pipeline in badge. !5862 + - Add test coverage report badge. !5708 + - Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar) + - Add Koding (online IDE) integration + - Ability to specify branches for Pivotal Tracker integration (Egor Lynko) - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres) + - Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres) + - Fix adding line comments on the initial commit to a repo !5900 - Fix the title of the toggle dropdown button. !5515 (herminiotorres) + - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz) + - Update to Ruby 2.3.1. !4948 + - Add Issues Board !5548 + - Allow resolving merge conflicts in the UI !5479 - Improve diff performance by eliminating redundant checks for text blobs + - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi) - Convert switch icon into icon font (ClemMakesApps) + - API: Endpoints for enabling and disabling deploy keys + - API: List access requests, request access, approve, and deny access requests to a project or a group. !4833 + - Use long options for curl examples in documentation !5703 (winniehell) + - Added tooltip listing label names to the labels value in the collapsed issuable sidebar - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) + - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell) + - Allow naming U2F devices !5833 + - Ignore URLs starting with // in Markdown links !5677 (winniehell) - Fix CI status icon link underline (ClemMakesApps) - The Repository class is now instrumented + - Fix commit mention font inconsistency (ClemMakesApps) + - Do not escape URI when extracting path !5878 (winniehell) + - Fix filter label tooltip HTML rendering (ClemMakesApps) - Cache the commit author in RequestStore to avoid extra lookups in PostReceive - Expand commit message width in repo view (ClemMakesApps) - Cache highlighted diff lines for merge requests + - Pre-create all builds for a Pipeline when the new Pipeline is created !5295 + - Allow merge request diff notes and discussions to be explicitly marked as resolved + - API: Add deployment endpoints + - API: Add Play endpoint on Builds - Fix of 'Commits being passed to custom hooks are already reachable when using the UI' + - Show wall clock time when showing a pipeline. !5734 + - Show member roles to all users on members page + - Project.visible_to_user is instrumented again + - Fix awardable button mutuality loading spinners (ClemMakesApps) + - Sort todos by date and priority - Add support for using RequestStore within Sidekiq tasks via SIDEKIQ_REQUEST_STORE env variable - Optimize maximum user access level lookup in loading of notes + - Send notification emails to users newly mentioned in issue and MR edits !5800 - Add "No one can push" as an option for protected branches. !5081 - Improve performance of AutolinkFilter#text_parse by using XPath + - Add experimental Redis Sentinel support !1877 + - Rendering of SVGs as blobs is now limited to SVGs with a size smaller or equal to 2MB + - Fix branches page dropdown sort initial state (ClemMakesApps) - Environments have an url to link to + - Various redundant database indexes have been removed + - Update `timeago` plugin to use multiple string/locale settings - Remove unused images (ClemMakesApps) - Check remove source branch by default on a new MR + - Get issue and merge request description templates from repositories + - Enforce 2FA restrictions on API authentication endpoints !5820 - Limit git rev-list output count to one in forced push check + - Show deployment status on merge requests with external URLs - Clean up unused routes (Josef Strzibny) + - Fix issue on empty project to allow developers to only push to protected branches if given permission + - API: Add enpoints for pipelines - Add green outline to New Branch button. !5447 (winniehell) + - Optimize generating of cache keys for issues and notes + - Fix repository push email formatting in Outlook - Improve performance of syntax highlighting Markdown code blocks - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects - Remove delay when hitting "Reply..." button on page with a lot of discussions - Retrieve rendered HTML from cache in one request - Fix renaming repository when name contains invalid chararacters under project settings + - Upgrade Grape from 0.13.0 to 0.15.0. !4601 + - Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries + - Fix devise deprecation warnings. + - Check for 2FA when using Git over HTTP and only allow PersonalAccessTokens as password in that case !5764 + - Update version_sorter and use new interface for faster tag sorting - Optimize checking if a user has read access to a list of issues !5370 + - Store all DB secrets in secrets.yml, under descriptive names !5274 + - Fix syntax highlighting in file editor + - Support slash commands in issue and merge request descriptions as well as comments. !5021 - Nokogiri's various parsing methods are now instrumented + - Add archived badge to project list !5798 - Add simple identifier to public SSH keys (muteor) - - Add a way to send an email and create an issue based on private personal token. Find the email address from issues page. !3363 + - Admin page now references docs instead of a specific file !5600 (AnAverageHuman) - Fix filter input alignment (ClemMakesApps) - Include old revision in merge request update hooks (Ben Boeckel) - Add build event color in HipChat messages (David Eisner) - Make fork counter always clickable. !5463 (winniehell) + - Document that webhook secret token is sent in X-Gitlab-Token HTTP header !5664 (lycoperdon) - Gitlab::Highlight is now instrumented - All created issues, API or WebUI, can be submitted to Akismet for spam check !5333 + - Allow users to import cross-repository pull requests from GitHub - The overhead of instrumented method calls has been reduced - Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le) - Load project invited groups and members eagerly in `ProjectTeam#fetch_members` + - Add pipeline events hook - Bump gitlab_git to speedup DiffCollection iterations - Rewrite description of a blocked user in admin settings. (Elias Werberich) - Make branches sortable without push permission !5462 (winniehell) - Check for Ci::Build artifacts at database level on pipeline partial - Convert image diff background image to CSS (ClemMakesApps) + - Remove unnecessary index_projects_on_builds_enabled index from the projects table - Make "New issue" button in Issue page less obtrusive !5457 (winniehell) - Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration - Fix search for notes which belongs to deleted objects + - Allow Akismet to be trained by submitting issues as spam or ham !5538 - Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska) - Allow branch names ending with .json for graph and network page !5579 (winniehell) - Add the `sprockets-es6` gem + - Improve OAuth2 client documentation (muteor) + - Fix diff comments inverted toggle bug (ClemMakesApps) - Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska) - Profile requests when a header is passed - Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab. - Speedup DiffNote#active? on discussions, preloading noteables and avoid touching git repository to return diff_refs when possible - Add commit stats in commit api. !5517 (dixpac) - Add CI configuration button on project page + - Fix merge request new view not changing code view rendering style + - edit_blob_link will use blob passed onto the options parameter - Make error pages responsive (Takuya Noguchi) + - The performance of the project dropdown used for moving issues has been improved - Fix skip_repo parameter being ignored when destroying a namespace + - Add all builds into stage/job dropdowns on builds page - Change requests_profiles resource constraint to catch virtually any file + - Bump gitlab_git to lazy load compare commits - Reduce number of queries made for merge_requests/:id/diffs + - Add the option to set the expiration date for the project membership when giving a user access to a project. !5599 (Adam Niedzielski) - Sensible state specific default sort order for issues and merge requests !5453 (tomb0y) + - Fix bug where destroying a namespace would not always destroy projects - Fix RequestProfiler::Middleware error when code is reloaded in development + - Allow horizontal scrolling of code blocks in issue body - Catch what warden might throw when profiling requests to re-throw it + - Avoid commit lookup on diff_helper passing existing local variable to the helper method + - Add description to new_issue email and new_merge_request_email in text/plain content type. !5663 (dixpac) - Speed up and reduce memory usage of Commit#repo_changes, Repository#expire_avatar_cache and IrkerWorker - -v 8.10.4 (unreleased) - - Fix Import/Export error checking versions + - Add unfold links for Side-by-Side view. !5415 (Tim Masliuchenko) + - Adds support for pending invitation project members importing projects + - Add pipeline visualization/graph on pipeline page + - Update devise initializer to turn on changed password notification emails. !5648 (tombell) + - Avoid to show the original password field when password is automatically set. !5712 (duduribeiro) + - Fix importing GitLab projects with an invalid MR source project + - Sort folders with submodules in Files view !5521 + - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0 + - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska) + - Add pipelines tab to merge requests + - Fix notification_service argument error of declined invitation emails + - Fix a memory leak caused by Banzai::Filter::SanitizationFilter + - Speed up todos queries by limiting the projects set we join with + - Ensure file editing in UI does not overwrite commited changes without warning user + - Eliminate unneeded calls to Repository#blob_at when listing commits with no path + - Update gitlab_git gem to 10.4.7 + - Simplify SQL queries of marking a todo as done + +v 8.10.12 + - Don't send Private-Token (API authentication) headers to Sentry + - Share projects via the API only with groups the authenticated user can access + +v 8.10.11 + - Respect the fork_project permission when forking projects + - Set a restrictive CORS policy on the API for credentialed requests + - API: disable rails session auth for non-GET/HEAD requests + - Escape HTML nodes in builds commands in CI linter + +v 8.10.10 + - Allow the Rails cookie to be used for API authentication. + +v 8.10.9 + - Exclude some pending or inactivated rows in Member scopes + +v 8.10.8 + - Fix information disclosure in issue boards. + - Fix privilege escalation in project import. + +v 8.10.7 + - Upgrade Hamlit to 2.6.1. !5873 + - Upgrade Doorkeeper to 4.2.0. !5881 + +v 8.10.6 + - Upgrade Rails to 4.2.7.1 for security fixes. !5781 + - Restore "Largest repository" sort option on Admin > Projects page. !5797 + - Fix privilege escalation via project export. + - Require administrator privileges to perform a project import. + +v 8.10.5 + - Add a data migration to fix some missing timestamps in the members table. !5670 + - Revert the "Defend against 'Host' header injection" change in the source NGINX templates. !5706 + - Cache project count for 5 minutes to reduce DB load. !5746 & !5754 + +v 8.10.4 + - Don't close referenced upstream issues from a forked project. + - Fixes issue with dropdowns `enter` key not working correctly. !5544 + - Fix Import/Export project import not working in HA mode. !5618 + - Fix Import/Export error checking versions. !5638 v 8.10.3 - Fix Import/Export issue importing milestones and labels not associated properly. !5426 @@ -79,6 +637,7 @@ v 8.10.3 - Fix importer for GitHub Pull Requests when a branch was removed. !5573 - Ignore invalid IPs in X-Forwarded-For when trusted proxies are configured. !5584 - Trim extra displayed carriage returns in diffs and files with CRLFs. !5588 + - Fix label already exist error message in the right sidebar. v 8.10.2 - User can now search branches by name. !5144 @@ -157,6 +716,9 @@ v 8.10.0 - Fix check for New Branch button on Issue page. !4630 (winniehell) - Fix GFM autocomplete not working on wiki pages - Fixed enter key not triggering click on first row when searching in a dropdown + - Updated dropdowns in issuable form to use new GitLab dropdown style + - Make images fit to the size of the viewport !4810 + - Fix check for New Branch button on Issue page !4630 (winniehell) - Fix MR-auto-close text added to description. !4836 - Support U2F devices in Firefox. !5177 - Fix issue, preventing users w/o push access to sort tags. !5105 (redetection) @@ -219,9 +781,11 @@ v 8.10.0 - Fix new snippet style bug (elliotec) - Instrument Rinku usage - Be explicit to define merge request discussion variables + - Use cache for todos counter calling TodoService - Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab - RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info. - Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w) + - Made project list visibility icon fixed width - Set import_url validation to be more strict - Memoize MR merged/closed events retrieval - Don't render discussion notes when requesting diff tab through AJAX @@ -267,6 +831,26 @@ v 8.10.0 - Export and import avatar as part of project import/export - Fix migration corrupting import data for old version upgrades - Show tooltip on GitLab export link in new project page + - Fix import_data wrongly saved as a result of an invalid import_url !5206 + +v 8.9.11 + - Respect the fork_project permission when forking projects + - Set a restrictive CORS policy on the API for credentialed requests + - API: disable rails session auth for non-GET/HEAD requests + - Escape HTML nodes in builds commands in CI linter + +v 8.9.10 + - Allow the Rails cookie to be used for API authentication. + +v 8.9.9 + - Exclude some pending or inactivated rows in Member scopes + +v 8.9.8 + - Upgrade Doorkeeper to 4.2.0. !5881 + +v 8.9.7 + - Upgrade Rails to 4.2.7.1 for security fixes. !5781 + - Require administrator privileges to perform a project import. v 8.9.6 - Fix importing of events under notes for GitLab projects. !5154 @@ -277,12 +861,6 @@ v 8.9.6 - Keeps issue number when importing from Gitlab.com - Add Pending tab for Builds (Katarzyna Kobierska, Urszula Budziszewska) -v 8.9.7 (unreleased) - - Fix import_data wrongly saved as a result of an invalid import_url - -v 8.9.6 - - Fix importing of events under notes for GitLab projects - v 8.9.5 - Add more debug info to import/export and memory killer. !5108 - Fixed avatar alignment in new MR view. !5095 @@ -533,6 +1111,12 @@ v 8.9.0 - Add tooltip to pin/unpin navbar - Add new sub nav style to Wiki and Graphs sub navigation +v 8.8.9 + - Upgrade Doorkeeper to 4.2.0. !5881 + +v 8.8.8 + - Upgrade Rails to 4.2.7.1 for security fixes. !5781 + v 8.8.7 - Fix privilege escalation issue with OAuth external users. - Ensure references to private repos aren't shown to logged-out users. @@ -1542,7 +2126,7 @@ v 8.1.3 - Use issue editor as cross reference comment author when issue is edited with a new mention - Add Facebook authentication -v 8.1.1 +v 8.1.2 - Fix cloning Wiki repositories via HTTP (Stan Hu) - Add migration to remove satellites directory - Fix specific runners visibility @@ -1741,1692 +2325,5 @@ v 8.0.0 - Redirect from incorrectly cased group or project path to correct one (Francesco Levorato) - Removed API calls from CE to CI -v 7.14.3 - - No changes - -v 7.14.2 - - Upgrade gitlab_git to 7.2.15 to fix `git blame` errors with ISO-encoded files (Stan Hu) - - Allow configuration of LDAP attributes GitLab will use for the new user account. - -v 7.14.1 - - Improve abuse reports management from admin area - - Fix "Reload with full diff" URL button in compare branch view (Stan Hu) - - Disabled DNS lookups for SSH in docker image (Rowan Wookey) - - Only include base URL in OmniAuth full_host parameter (Stan Hu) - - Fix Error 500 in API when accessing a group that has an avatar (Stan Hu) - - Ability to enable SSL verification for Webhooks - -v 7.14.0 - - Fix bug where non-project members of the target project could set labels on new merge requests. - - Update default robots.txt rules to disallow crawling of irrelevant pages (Ben Bodenmiller) - - Fix redirection after sign in when using auto_sign_in_with_provider - - Upgrade gitlab_git to 7.2.14 to ignore CRLFs in .gitmodules (Stan Hu) - - Clear cache to prevent listing deleted branches after MR removes source branch (Stan Hu) - - Provide more feedback what went wrong if HipChat service failed test (Stan Hu) - - Fix bug where backslashes in inline diffs could be dropped (Stan Hu) - - Disable turbolinks when linking to Bitbucket import status (Stan Hu) - - Fix broken code import and display error messages if something went wrong with creating project (Stan Hu) - - Fix corrupted binary files when using API files endpoint (Stan Hu) - - Bump Haml to 4.0.7 to speed up textarea rendering (Stan Hu) - - Show incompatible projects in Bitbucket import status (Stan Hu) - - Fix coloring of diffs on MR Discussion-tab (Gert Goet) - - Fix "Network" and "Graphs" pages for branches with encoded slashes (Stan Hu) - - Fix errors deleting and creating branches with encoded slashes (Stan Hu) - - Always add current user to autocomplete controller to support filter by "Me" (Stan Hu) - - Fix multi-line syntax highlighting (Stan Hu) - - Fix network graph when branch name has single quotes (Stan Hu) - - Add "Confirm user" button in user admin page (Stan Hu) - - Upgrade gitlab_git to version 7.2.6 to fix Error 500 when creating network graphs (Stan Hu) - - Add support for Unicode filenames in relative links (Hiroyuki Sato) - - Fix URL used for refreshing notes if relative_url is present (Bartłomiej Święcki) - - Fix commit data retrieval when branch name has single quotes (Stan Hu) - - Check that project was actually created rather than just validated in import:repos task (Stan Hu) - - Fix full screen mode for snippet comments (Daniel Gerhardt) - - Fix 404 error in files view after deleting the last file in a repository (Stan Hu) - - Fix the "Reload with full diff" URL button (Stan Hu) - - Fix label read access for unauthenticated users (Daniel Gerhardt) - - Fix access to disabled features for unauthenticated users (Daniel Gerhardt) - - Fix OAuth provider bug where GitLab would not go return to the redirect_uri after sign-in (Stan Hu) - - Fix file upload dialog for comment editing (Daniel Gerhardt) - - Set OmniAuth full_host parameter to ensure redirect URIs are correct (Stan Hu) - - Return comments in created order in merge request API (Stan Hu) - - Disable internal issue tracker controller if external tracker is used (Stan Hu) - - Expire Rails cache entries after two weeks to prevent endless Redis growth - - Add support for destroying project milestones (Stan Hu) - - Allow custom backup archive permissions - - Add project star and fork count, group avatar URL and user/group web URL attributes to API - - Show who last edited a comment if it wasn't the original author - - Send notification to all participants when MR is merged. - - Add ability to manage user email addresses via the API. - - Show buttons to add license, changelog and contribution guide if they're missing. - - Tweak project page buttons. - - Disabled autocapitalize and autocorrect on login field (Daryl Chan) - - Mention group and project name in creation, update and deletion notices (Achilleas Pipinellis) - - Update gravatar link on profile page to link to configured gravatar host (Ben Bodenmiller) - - Remove redis-store TTL monkey patch - - Add support for CI skipped status - - Fetch code from forks to refs/merge-requests/:id/head when merge request created - - Remove comments and email addresses when publicly exposing ssh keys (Zeger-Jan van de Weg) - - Add "Check out branch" button to the MR page. - - Improve MR merge widget text and UI consistency. - - Improve text in MR "How To Merge" modal. - - Cache all events - - Order commits by date when comparing branches - - Fix bug causing error when the target branch of a symbolic ref was deleted - - Include branch/tag name in archive file and directory name - - Add dropzone upload progress - - Add a label for merged branches on branches page (Florent Baldino) - - Detect .mkd and .mkdn files as markdown (Ben Boeckel) - - Fix: User search feature in admin area does not respect filters - - Set max-width for README, issue and merge request description for easier read on big screens - - Update Flowdock integration to support new Flowdock API (Boyan Tabakov) - - Remove author from files view (Sven Strickroth) - - Fix infinite loop when SAML was incorrectly configured. - -v 7.13.5 - - Satellites reverted - -v 7.13.4 - - Allow users to send abuse reports - -v 7.13.3 - - Fix bug causing Bitbucket importer to crash when OAuth application had been removed. - - Allow users to send abuse reports - - Remove satellites - - Link username to profile on Group Members page (Tom Webster) - -v 7.13.2 - - Fix randomly failed spec - - Create project services on Project creation - - Add admin_merge_request ability to Developer level and up - - Fix Error 500 when browsing projects with no HEAD (Stan Hu) - - Fix labels / assignee / milestone for the merge requests when issues are disabled - - Show the first tab automatically on MergeRequests#new - - Add rake task 'gitlab:update_commit_count' (Daniel Gerhardt) - - Fix Gmail Actions - -v 7.13.1 - - Fix: Label modifications are not reflected in existing notes and in the issue list - - Fix: Label not shown in the Issue list, although it's set through web interface - - Fix: Group/project references are linked incorrectly - - Improve documentation - - Fix of migration: Check if session_expire_delay column exists before adding the column - - Fix: ActionView::Template::Error - - Fix: "Create Merge Request" isn't always shown in event for newly pushed branch - - Fix bug causing "Remove source-branch" option not to work for merge requests from the same project. - - Render Note field hints consistently for "new" and "edit" forms - -v 7.13.0 - - Remove repository graph log to fix slow cache updates after push event (Stan Hu) - - Only enable HSTS header for HTTPS and port 443 (Stan Hu) - - Fix user autocomplete for unauthenticated users accessing public projects (Stan Hu) - - Fix redirection to home page URL for unauthorized users (Daniel Gerhardt) - - Add branch switching support for graphs (Daniel Gerhardt) - - Fix external issue tracker hook/test for HTTPS URLs (Daniel Gerhardt) - - Remove link leading to a 404 error in Deploy Keys page (Stan Hu) - - Add support for unlocking users in admin settings (Stan Hu) - - Add Irker service configuration options (Stan Hu) - - Fix order of issues imported from GitHub (Hiroyuki Sato) - - Bump rugments to 1.0.0beta8 to fix C prototype function highlighting (Jonathon Reinhart) - - Fix Merge Request webhook to properly fire "merge" action when accepted from the web UI - - Add `two_factor_enabled` field to admin user API (Stan Hu) - - Fix invalid timestamps in RSS feeds (Rowan Wookey) - - Fix downloading of patches on public merge requests when user logged out (Stan Hu) - - Fix Error 500 when relative submodule resolves to a namespace that has a different name from its path (Stan Hu) - - Extract the longest-matching ref from a commit path when multiple matches occur (Stan Hu) - - Update maintenance documentation to explain no need to recompile asssets for omnibus installations (Stan Hu) - - Support commenting on diffs in side-by-side mode (Stan Hu) - - Fix JavaScript error when clicking on the comment button on a diff line that has a comment already (Stan Hu) - - Return 40x error codes if branch could not be deleted in UI (Stan Hu) - - Remove project visibility icons from dashboard projects list - - Rename "Design" profile settings page to "Preferences". - - Allow users to customize their default Dashboard page. - - Update ssl_ciphers in Nginx example to remove DHE settings. This will deny forward secrecy for Android 2.3.7, Java 6 and OpenSSL 0.9.8 - - Admin can edit and remove user identities - - Convert CRLF newlines to LF when committing using the web editor. - - API request /projects/:project_id/merge_requests?state=closed will return only closed merge requests without merged one. If you need ones that were merged - use state=merged. - - Allow Administrators to filter the user list by those with or without Two-factor Authentication enabled. - - Show a user's Two-factor Authentication status in the administration area. - - Explicit error when commit not found in the CI - - Improve performance for issue and merge request pages - - Users with guest access level can not set assignee, labels or milestones for issue and merge request - - Reporter role can manage issue tracker now: edit any issue, set assignee or milestone and manage labels - - Better performance for pages with events list, issues list and commits list - - Faster automerge check and merge itself when source and target branches are in same repository - - Correctly show anonymous authorized applications under Profile > Applications. - - Query Optimization in MySQL. - - Allow users to be blocked and unblocked via the API - - Use native Postgres database cleaning during backup restore - - Redesign project page. Show README as default instead of activity. Move project activity to separate page - - Make left menu more hierarchical and less contextual by adding back item at top - - A fork can’t have a visibility level that is greater than the original project. - - Faster code search in repository and wiki. Fixes search page timeout for big repositories - - Allow administrators to disable 2FA for a specific user - - Add error message for SSH key linebreaks - - Store commits count in database (will populate with valid values only after first push) - - Rebuild cache after push to repository in background job - - Fix transferring of project to another group using the API. - -v 7.12.2 - - Correctly show anonymous authorized applications under Profile > Applications. - - Faster automerge check and merge itself when source and target branches are in same repository - - Audit log for user authentication - - Allow custom label to be set for authentication providers. - -v 7.12.1 - - Fix error when deleting a user who has projects (Stan Hu) - - Fix post-receive errors on a push when an external issue tracker is configured (Stan Hu) - - Add SAML to list of social_provider (Matt Firtion) - - Fix merge requests API scope to keep compatibility in 7.12.x patch release (Dmitriy Zaporozhets) - - Fix closed merge request scope at milestone page (Dmitriy Zaporozhets) - - Revert merge request states renaming - - Fix hooks for web based events with external issue references (Daniel Gerhardt) - - Improve performance for issue and merge request pages - - Compress database dumps to reduce backup size - -v 7.12.0 - - Fix Error 500 when one user attempts to access a personal, internal snippet (Stan Hu) - - Disable changing of target branch in new merge request page when a branch has already been specified (Stan Hu) - - Fix post-receive errors on a push when an external issue tracker is configured (Stan Hu) - - Update oauth button logos for Twitter and Google to recommended assets - - Update browser gem to version 0.8.0 for IE11 support (Stan Hu) - - Fix timeout when rendering file with thousands of lines. - - Add "Remember me" checkbox to LDAP signin form. - - Add session expiration delay configuration through UI application settings - - Don't notify users mentioned in code blocks or blockquotes. - - Omit link to generate labels if user does not have access to create them (Stan Hu) - - Show warning when a comment will add 10 or more people to the discussion. - - Disable changing of the source branch in merge request update API (Stan Hu) - - Shorten merge request WIP text. - - Add option to disallow users from registering any application to use GitLab as an OAuth provider - - Support editing target branch of merge request (Stan Hu) - - Refactor permission checks with issues and merge requests project settings (Stan Hu) - - Fix Markdown preview not working in Edit Milestone page (Stan Hu) - - Fix Zen Mode not closing with ESC key (Stan Hu) - - Allow HipChat API version to be blank and default to v2 (Stan Hu) - - Add file attachment support in Milestone description (Stan Hu) - - Fix milestone "Browse Issues" button. - - Set milestone on new issue when creating issue from index with milestone filter active. - - Make namespace API available to all users (Stan Hu) - - Add webhook support for note events (Stan Hu) - - Disable "New Issue" and "New Merge Request" buttons when features are disabled in project settings (Stan Hu) - - Remove Rack Attack monkey patches and bump to version 4.3.0 (Stan Hu) - - Fix clone URL losing selection after a single click in Safari and Chrome (Stan Hu) - - Fix git blame syntax highlighting when different commits break up lines (Stan Hu) - - Add "Resend confirmation e-mail" link in profile settings (Stan Hu) - - Allow to configure location of the `.gitlab_shell_secret` file. (Jakub Jirutka) - - Disabled expansion of top/bottom blobs for new file diffs - - Update Asciidoctor gem to version 1.5.2. (Jakub Jirutka) - - Fix resolving of relative links to repository files in AsciiDoc documents. (Jakub Jirutka) - - Use the user list from the target project in a merge request (Stan Hu) - - Default extention for wiki pages is now .md instead of .markdown (Jeroen van Baarsen) - - Add validation to wiki page creation (only [a-zA-Z0-9/_-] are allowed) (Jeroen van Baarsen) - - Fix new/empty milestones showing 100% completion value (Jonah Bishop) - - Add a note when an Issue or Merge Request's title changes - - Consistently refer to MRs as either Merged or Closed. - - Add Merged tab to MR lists. - - Prefix EmailsOnPush email subject with `[Git]`. - - Group project contributions by both name and email. - - Clarify navigation labels for Project Settings and Group Settings. - - Move user avatar and logout button to sidebar - - You can not remove user if he/she is an only owner of group - - User should be able to leave group. If not - show him proper message - - User has ability to leave project - - Add SAML support as an omniauth provider - - Allow to configure a URL to show after sign out - - Add an option to automatically sign-in with an Omniauth provider - - GitLab CI service sends .gitlab-ci.yml in each push call - - When remove project - move repository and schedule it removal - - Improve group removing logic - - Trigger create-hooks on backup restore task - - Add option to automatically link omniauth and LDAP identities - - Allow special character in users bio. I.e.: I <3 GitLab - -v 7.11.4 - - Fix missing bullets when creating lists - - Set rel="nofollow" on external links - -v 7.11.3 - - no changes - - Fix upgrader script (Martins Polakovs) - -v 7.11.2 - - no changes - -v 7.11.1 - - no changes - -v 7.11.0 - - Fall back to Plaintext when Syntaxhighlighting doesn't work. Fixes some buggy lexers (Hannes Rosenögger) - - Get editing comments to work in Chrome 43 again. - - Fix broken view when viewing history of a file that includes a path that used to be another file (Stan Hu) - - Don't show duplicate deploy keys - - Fix commit time being displayed in the wrong timezone in some cases (Hannes Rosenögger) - - Make the first branch pushed to an empty repository the default HEAD (Stan Hu) - - Fix broken view when using a tag to display a tree that contains git submodules (Stan Hu) - - Make Reply-To config apply to change e-mail confirmation and other Devise notifications (Stan Hu) - - Add application setting to restrict user signups to e-mail domains (Stan Hu) - - Don't allow a merge request to be merged when its title starts with "WIP". - - Add a page title to every page. - - Allow primary email to be set to an email that you've already added. - - Fix clone URL field and X11 Primary selection (Dmitry Medvinsky) - - Ignore invalid lines in .gitmodules - - Fix "Cannot move project" error message from popping up after a successful transfer (Stan Hu) - - Redirect to sign in page after signing out. - - Fix "Hello @username." references not working by no longer allowing usernames to end in period. - - Fix "Revspec not found" errors when viewing diffs in a forked project with submodules (Stan Hu) - - Improve project page UI - - Fix broken file browsing with relative submodule in personal projects (Stan Hu) - - Add "Reply quoting selected text" shortcut key (`r`) - - Fix bug causing `@whatever` inside an issue's first code block to be picked up as a user mention. - - Fix bug causing `@whatever` inside an inline code snippet (backtick-style) to be picked up as a user mention. - - When use change branches link at MR form - save source branch selection instead of target one - - Improve handling of large diffs - - Added GitLab Event header for project hooks - - Add Two-factor authentication (2FA) for GitLab logins - - Show Atom feed buttons everywhere where applicable. - - Add project activity atom feed. - - Don't crash when an MR from a fork has a cross-reference comment from the target project on one of its commits. - - Explain how to get a new password reset token in welcome emails - - Include commit comments in MR from a forked project. - - Group milestones by title in the dashboard and all other issue views. - - Query issues, merge requests and milestones with their IID through API (Julien Bianchi) - - Add default project and snippet visibility settings to the admin web UI. - - Show incompatible projects in Google Code import status (Stan Hu) - - Fix bug where commit data would not appear in some subdirectories (Stan Hu) - - Task lists are now usable in comments, and will show up in Markdown previews. - - Fix bug where avatar filenames were not actually deleted from the database during removal (Stan Hu) - - Fix bug where Slack service channel was not saved in admin template settings. (Stan Hu) - - Protect OmniAuth request phase against CSRF. - - Don't send notifications to mentioned users that don't have access to the project in question. - - Add search issues/MR by number - - Change plots to bar graphs in commit statistics screen - - Move snippets UI to fluid layout - - Improve UI for sidebar. Increase separation between navigation and content - - Improve new project command options (Ben Bodenmiller) - - Add common method to force UTF-8 and use it to properly handle non-ascii OAuth user properties (Onur Küçük) - - Prevent sending empty messages to HipChat (Chulki Lee) - - Improve UI for mobile phones on dashboard and project pages - - Add room notification and message color option for HipChat - - Allow to use non-ASCII letters and dashes in project and namespace name. (Jakub Jirutka) - - Add footnotes support to Markdown (Guillaume Delbergue) - - Add current_sign_in_at to UserFull REST api. - - Make Sidekiq MemoryKiller shutdown signal configurable - - Add "Create Merge Request" buttons to commits and branches pages and push event. - - Show user roles by comments. - - Fix automatic blocking of auto-created users from Active Directory. - - Call merge request webhook for each new commits (Arthur Gautier) - - Use SIGKILL by default in Sidekiq::MemoryKiller - - Fix mentioning of private groups. - - Add style for <kbd> element in markdown - - Spin spinner icon next to "Checking for CI status..." on MR page. - - Fix reference links in dashboard activity and ATOM feeds. - - Ensure that the first added admin performs repository imports - -v 7.10.4 - - Fix migrations broken in 7.10.2 - - Make tags for GitLab installations running on MySQL case sensitive - - Get Gitorious importer to work again. - - Fix adding new group members from admin area - - Fix DB error when trying to tag a repository (Stan Hu) - - Fix Error 500 when searching Wiki pages (Stan Hu) - - Unescape branch names in compare commit (Stan Hu) - - Order commit comments chronologically in API. - -v 7.10.2 - - Fix CI links on MR page - -v 7.10.0 - - Ignore submodules that are defined in .gitmodules but are checked in as directories. - - Allow projects to be imported from Google Code. - - Remove access control for uploaded images to fix broken images in emails (Hannes Rosenögger) - - Allow users to be invited by email to join a group or project. - - Don't crash when project repository doesn't exist. - - Add config var to block auto-created LDAP users. - - Don't use HTML ellipsis in EmailsOnPush subject truncated commit message. - - Set EmailsOnPush reply-to address to committer email when enabled. - - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - - Fix persistent XSS vulnerability around profile website URLs. - - Fix project import URL regex to prevent arbitary local repos from being imported. - - Fix directory traversal vulnerability around uploads routes. - - Fix directory traversal vulnerability around help pages. - - Don't leak existence of project via search autocomplete. - - Don't leak existence of group or project via search. - - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - - Add a rake task to check repository integrity with `git fsck` - - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) - - Move current user to the top of the list in assignee/author filters (Stan Hu) - - Fix broken side-by-side diff view on merge request page (Stan Hu) - - Set Application controller default URL options to ensure all url_for calls are consistent (Stan Hu) - - Allow HTML tags in Markdown input - - Fix code unfold not working on Compare commits page (Stan Hu) - - Fix generating SSH key fingerprints with OpenSSH 6.8. (Sašo Stanovnik) - - Fix "Import projects from" button to show the correct instructions (Stan Hu) - - Fix dots in Wiki slugs causing errors (Stan Hu) - - Make maximum attachment size configurable via Application Settings (Stan Hu) - - Update poltergeist to version 1.6.0 to support PhantomJS 2.0 (Zeger-Jan van de Weg) - - Fix cross references when usernames, milestones, or project names contain underscores (Stan Hu) - - Disable reference creation for comments surrounded by code/preformatted blocks (Stan Hu) - - Reduce Rack Attack false positives causing 403 errors during HTTP authentication (Stan Hu) - - enable line wrapping per default and remove the checkbox to toggle it (Hannes Rosenögger) - - Fix a link in the patch update guide - - Add a service to support external wikis (Hannes Rosenögger) - - Omit the "email patches" link and fix plain diff view for merge commits - - List new commits for newly pushed branch in activity view. - - Add sidetiq gem dependency to match EE - - Add changelog, license and contribution guide links to project tab bar. - - Improve diff UI - - Fix alignment of navbar toggle button (Cody Mize) - - Fix checkbox rendering for nested task lists - - Identical look of selectboxes in UI - - Upgrade the gitlab_git gem to version 7.1.3 - - Move "Import existing repository by URL" option to button. - - Improve error message when save profile has error. - - Passing the name of pushed ref to CI service (requires GitLab CI 7.9+) - - Add location field to user profile - - Fix print view for markdown files and wiki pages - - Fix errors when deleting old backups - - Improve GitLab performance when working with git repositories - - Add tag message and last commit to tag hook (Kamil Trzciński) - - Restrict permissions on backup files - - Improve oauth accounts UI in profile page - - Add ability to unlink connected accounts - - Replace commits calendar with faster contribution calendar that includes issues and merge requests - - Add inifinite scroll to user page activity - - Don't include system notes in issue/MR comment count. - - Don't mark merge request as updated when merge status relative to target branch changes. - - Link note avatar to user. - - Make Git-over-SSH errors more descriptive. - - Fix EmailsOnPush. - - Refactor issue filtering - - AJAX selectbox for issue assignee and author filters - - Fix issue with missing options in issue filtering dropdown if selected one - - Prevent holding Control-Enter or Command-Enter from posting comment multiple times. - - Prevent note form from being cleared when submitting failed. - - Improve file icons rendering on tree (Sullivan Sénéchal) - - API: Add pagination to project events - - Get issue links in notification mail to work again. - - Don't show commit comment button when user is not signed in. - - Fix admin user projects lists. - - Don't leak private group existence by redirecting from namespace controller to group controller. - - Ability to skip some items from backup (database, respositories or uploads) - - Archive repositories in background worker. - - Import GitHub, Bitbucket or GitLab.com projects owned by authenticated user into current namespace. - - Project labels are now available over the API under the "tag_list" field (Cristian Medina) - - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - - Fix and improve help rendering (Sullivan Sénéchal) - - Fix final line in EmailsOnPush email diff being rendered as error. - - Prevent duplicate Buildkite service creation. - - Fix git over ssh errors 'fatal: protocol error: bad line length character' - - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled - - Bust group page project list cache when namespace name or path changes. - - Explicitly set image alt-attribute to prevent graphical glitches if gravatars could not be loaded - - Allow user to choose a public email to show on public profile - - Remove truncation from issue titles on milestone page (Jason Blanchard) - - Fix stuck Merge Request merging events from old installations (Ben Bodenmiller) - - Fix merge request comments on files with multiple commits - - Fix Resource Owner Password Authentication Flow - - Add icons to Add dropdown items. - - Allow admin to create public deploy keys that are accessible to any project. - - Warn when gitlab-shell version doesn't match requirement. - - Skip email confirmation when set by admin or via LDAP. - - Only allow users to reference groups, projects, issues, MRs, commits they have access to. - -v 7.9.4 - - Security: Fix project import URL regex to prevent arbitary local repos from being imported - - Fixed issue where only 25 commits would load in file listings - - Fix LDAP identities after config update - -v 7.9.3 - - Contains no changes - -v 7.9.2 - - Contains no changes - -v 7.9.1 - - Include missing events and fix save functionality in admin service template settings form (Stan Hu) - - Fix "Import projects from" button to show the correct instructions (Stan Hu) - - Fix OAuth2 issue importing a new project from GitHub and GitLab (Stan Hu) - - Fix for LDAP with commas in DN - - Fix missing events and in admin Slack service template settings form (Stan Hu) - - Don't show commit comment button when user is not signed in. - - Downgrade gemnasium-gitlab-service gem - -v 7.9.0 - - Add HipChat integration documentation (Stan Hu) - - Update documentation for object_kind field in Webhook push and tag push Webhooks (Stan Hu) - - Fix broken email images (Hannes Rosenögger) - - Automatically config git if user forgot, where possible (Zeger-Jan van de Weg) - - Fix mass SQL statements on initial push (Hannes Rosenögger) - - Add tag push notifications and normalize HipChat and Slack messages to be consistent (Stan Hu) - - Add comment notification events to HipChat and Slack services (Stan Hu) - - Add issue and merge request events to HipChat and Slack services (Stan Hu) - - Fix merge request URL passed to Webhooks. (Stan Hu) - - Fix bug that caused a server error when editing a comment to "+1" or "-1" (Stan Hu) - - Fix code preview theme setting for comments, issues, merge requests, and snippets (Stan Hu) - - Move labels/milestones tabs to sidebar - - Upgrade Rails gem to version 4.1.9. - - Improve error messages for file edit failures - - Improve UI for commits, issues and merge request lists - - Fix commit comments on first line of diff not rendering in Merge Request Discussion view. - - Allow admins to override restricted project visibility settings. - - Move restricted visibility settings from gitlab.yml into the web UI. - - Improve trigger merge request hook when source project branch has been updated (Kirill Zaitsev) - - Save web edit in new branch - - Fix ordering of imported but unchanged projects (Marco Wessel) - - Mobile UI improvements: make aside content expandable - - Expose avatar_url in projects API - - Fix checkbox alignment on the application settings page. - - Generalize image upload in drag and drop in markdown to all files (Hannes Rosenögger) - - Fix mass-unassignment of issues (Robert Speicher) - - Fix hidden diff comments in merge request discussion view - - Allow user confirmation to be skipped for new users via API - - Add a service to send updates to an Irker gateway (Romain Coltel) - - Add brakeman (security scanner for Ruby on Rails) - - Slack username and channel options - - Add grouped milestones from all projects to dashboard. - - Webhook sends pusher email as well as commiter - - Add Bitbucket omniauth provider. - - Add Bitbucket importer. - - Support referencing issues to a project whose name starts with a digit - - Condense commits already in target branch when updating merge request source branch. - - Send notifications and leave system comments when bulk updating issues. - - Automatically link commit ranges to compare page: sha1...sha4 or sha1..sha4 (includes sha1 in comparison) - - Move groups page from profile to dashboard - - Starred projects page at dashboard - - Blocking user does not remove him/her from project/groups but show blocked label - - Change subject of EmailsOnPush emails to include namespace, project and branch. - - Change subject of EmailsOnPush emails to include first commit message when multiple were pushed. - - Remove confusing footer from EmailsOnPush mail body. - - Add list of changed files to EmailsOnPush emails. - - Add option to send EmailsOnPush emails from committer email if domain matches. - - Add option to disable code diffs in EmailOnPush emails. - - Wrap commit message in EmailsOnPush email. - - Send EmailsOnPush emails when deleting commits using force push. - - Fix EmailsOnPush email comparison link to include first commit. - - Fix highliht of selected lines in file - - Reject access to group/project avatar if the user doesn't have access. - - Add database migration to clean group duplicates with same path and name (Make sure you have a backup before update) - - Add GitLab active users count to rake gitlab:check - - Starred projects page at dashboard - - Make email display name configurable - - Improve json validation in hook data - - Use Emoji One - - Updated emoji help documentation to properly reference EmojiOne. - - Fix missing GitHub organisation repositories on import page. - - Added blue theme - - Remove annoying notice messages when create/update merge request - - Allow smb:// links in Markdown text. - - Filter merge request by title or description at Merge Requests page - - Block user if he/she was blocked in Active Directory - - Fix import pages not working after first load. - - Use custom LDAP label in LDAP signin form. - - Execute hooks and services when branch or tag is created or deleted through web interface. - - Block and unblock user if he/she was blocked/unblocked in Active Directory - - Raise recommended number of unicorn workers from 2 to 3 - - Use same layout and interactivity for project members as group members. - - Prevent gitlab-shell character encoding issues by receiving its changes as raw data. - - Ability to unsubscribe/subscribe to issue or merge request - - Delete deploy key when last connection to a project is destroyed. - - Fix invalid Atom feeds when using emoji, horizontal rules, or images (Christian Walther) - - Backup of repositories with tar instead of git bundle (only now are git-annex files included in the backup) - - Add canceled status for CI - - Send EmailsOnPush email when branch or tag is created or deleted. - - Faster merge request processing for large repository - - Prevent doubling AJAX request with each commit visit via Turbolink - - Prevent unnecessary doubling of js events on import pages and user calendar - -v 7.8.4 - - Fix issue_tracker_id substitution in custom issue trackers - - Fix path and name duplication in namespaces - -v 7.8.3 - - Bump version of gitlab_git fixing annotated tags without message - -v 7.8.2 - - Fix service migration issue when upgrading from versions prior to 7.3 - - Fix setting of the default use project limit via admin UI - - Fix showing of already imported projects for GitLab and Gitorious importers - - Fix response of push to repository to return "Not found" if user doesn't have access - - Fix check if user is allowed to view the file attachment - - Fix import check for case sensetive namespaces - - Increase timeout for Git-over-HTTP requests to 1 hour since large pulls/pushes can take a long time. - - Properly handle autosave local storage exceptions. - - Escape wildcards when searching LDAP by username. - -v 7.8.1 - - Fix run of custom post receive hooks - - Fix migration that caused issues when upgrading to version 7.8 from versions prior to 7.3 - - Fix the warning for LDAP users about need to set password - - Fix avatars which were not shown for non logged in users - - Fix urls for the issues when relative url was enabled - -v 7.8.0 - - Fix access control and protection against XSS for note attachments and other uploads. - - Replace highlight.js with rouge-fork rugments (Stefan Tatschner) - - Make project search case insensitive (Hannes Rosenögger) - - Include issue/mr participants in list of recipients for reassign/close/reopen emails - - Expose description in groups API - - Better UI for project services page - - Cleaner UI for web editor - - Add diff syntax highlighting in email-on-push service notifications (Hannes Rosenögger) - - Add API endpoint to fetch all changes on a MergeRequest (Jeroen van Baarsen) - - View note image attachments in new tab when clicked instead of downloading them - - Improve sorting logic in UI and API. Explicitly define what sorting method is used by default - - Fix overflow at sidebar when have several items - - Add notes for label changes in issue and merge requests - - Show tags in commit view (Hannes Rosenögger) - - Only count a user's vote once on a merge request or issue (Michael Clarke) - - Increase font size when browse source files and diffs - - Service Templates now let you set default values for all services - - Create new file in empty repository using GitLab UI - - Ability to clone project using oauth2 token - - Upgrade Sidekiq gem to version 3.3.0 - - Stop git zombie creation during force push check - - Show success/error messages for test setting button in services - - Added Rubocop for code style checks - - Fix commits pagination - - Async load a branch information at the commit page - - Disable blacklist validation for project names - - Allow configuring protection of the default branch upon first push (Marco Wessel) - - Add gitlab.com importer - - Add an ability to login with gitlab.com - - Add a commit calendar to the user profile (Hannes Rosenögger) - - Submit comment on command-enter - - Notify all members of a group when that group is mentioned in a comment, for example: `@gitlab-org` or `@sales`. - - Extend issue clossing pattern to include "Resolve", "Resolves", "Resolved", "Resolving" and "Close" (Julien Bianchi and Hannes Rosenögger) - - Fix long broadcast message cut-off on left sidebar (Visay Keo) - - Add Project Avatars (Steven Thonus and Hannes Rosenögger) - - Password reset token validity increased from 2 hours to 2 days since it is also send on account creation. - - Edit group members via API - - Enable raw image paste from clipboard, currently Chrome only (Marco Cyriacks) - - Add action property to merge request hook (Julien Bianchi) - - Remove duplicates from group milestone participants list. - - Add a new API function that retrieves all issues assigned to a single milestone (Justin Whear and Hannes Rosenögger) - - API: Access groups with their path (Julien Bianchi) - - Added link to milestone and keeping resource context on smaller viewports for issues and merge requests (Jason Blanchard) - - Allow notification email to be set separately from primary email. - - API: Add support for editing an existing project (Mika Mäenpää and Hannes Rosenögger) - - Don't have Markdown preview fail for long comments/wiki pages. - - When test webhook - show error message instead of 500 error page if connection to hook url was reset - - Added support for firing system hooks on group create/destroy and adding/removing users to group (Boyan Tabakov) - - Added persistent collapse button for left side nav bar (Jason Blanchard) - - Prevent losing unsaved comments by automatically restoring them when comment page is loaded again. - - Don't allow page to be scaled on mobile. - - Clean the username acquired from OAuth/LDAP so it doesn't fail username validation and block signing up. - - Show assignees in merge request index page (Kelvin Mutuma) - - Link head panel titles to relevant root page. - - Allow users that signed up via OAuth to set their password in order to use Git over HTTP(S). - - Show users button to share their newly created public or internal projects on twitter - - Add quick help links to the GitLab pricing and feature comparison pages. - - Fix duplicate authorized applications in user profile and incorrect application client count in admin area. - - Make sure Markdown previews always use the same styling as the eventual destination. - - Remove deprecated Group#owner_id from API - - Show projects user contributed to on user page. Show stars near project on user page. - - Improve database performance for GitLab - - Add Asana service (Jeremy Benoist) - - Improve project webhooks with extra data - -v 7.7.2 - - Update GitLab Shell to version 2.4.2 that fixes a bug when developers can push to protected branch - - Fix issue when LDAP user can't login with existing GitLab account - -v 7.7.1 - - Improve mention autocomplete performance - - Show setup instructions for GitHub import if disabled - - Allow use http for OAuth applications - -v 7.7.0 - - Import from GitHub.com feature - - Add Jetbrains Teamcity CI service (Jason Lippert) - - Mention notification level - - Markdown preview in wiki (Yuriy Glukhov) - - Raise group avatar filesize limit to 200kb - - OAuth applications feature - - Show user SSH keys in admin area - - Developer can push to protected branches option - - Set project path instead of project name in create form - - Block Git HTTP access after 10 failed authentication attempts - - Updates to the messages returned by API (sponsored by O'Reilly Media) - - New UI layout with side navigation - - Add alert message in case of outdated browser (IE < 10) - - Added API support for sorting projects - - Update gitlab_git to version 7.0.0.rc14 - - Add API project search filter option for authorized projects - - Fix File blame not respecting branch selection - - Change some of application settings on fly in admin area UI - - Redesign signin/signup pages - - Close standard input in Gitlab::Popen.popen - - Trigger GitLab CI when push tags - - When accept merge request - do merge using sidaekiq job - - Enable web signups by default - - Fixes for diff comments: drag-n-drop images, selecting images - - Fixes for edit comments: drag-n-drop images, preview mode, selecting images, save & update - - Remove password strength indicator - -v 7.6.0 - - Fork repository to groups - - New rugged version - - Add CRON=1 backup setting for quiet backups - - Fix failing wiki restore - - Add optional Sidekiq MemoryKiller middleware (enabled via SIDEKIQ_MAX_RSS env variable) - - Monokai highlighting style now more faithful to original design (Mark Riedesel) - - Create project with repository in synchrony - - Added ability to create empty repo or import existing one if project does not have repository - - Reactivate highlight.js language autodetection - - Mobile UI improvements - - Change maximum avatar file size from 100KB to 200KB - - Strict validation for snippet file names - - Enable Markdown preview for issues, merge requests, milestones, and notes (Vinnie Okada) - - In the docker directory is a container template based on the Omnibus packages. - - Update Sidekiq to version 2.17.8 - - Add author filter to project issues and merge requests pages - - Atom feed for user activity - - Support multiple omniauth providers for the same user - - Rendering cross reference in issue title and tooltip for merge request - - Show username in comments - - Possibility to create Milestones or Labels when Issues are disabled - - Fix bug with showing gpg signature in tag - -v 7.5.3 - - Bump gitlab_git to 7.0.0.rc12 (includes Rugged 0.21.2) - -v 7.5.2 - - Don't log Sidekiq arguments by default - - Fix restore of wiki repositories from backups - -v 7.5.1 - - Add missing timestamps to 'members' table - -v 7.5.0 - - API: Add support for Hipchat (Kevin Houdebert) - - Add time zone configuration in gitlab.yml (Sullivan Senechal) - - Fix LDAP authentication for Git HTTP access - - Run 'GC.start' after every EmailsOnPushWorker job - - Fix LDAP config lookup for provider 'ldap' - - Drop all sequences during Postgres database restore - - Project title links to project homepage (Ben Bodenmiller) - - Add Atlassian Bamboo CI service (Drew Blessing) - - Mentioned @user will receive email even if he is not participating in issue or commit - - Session API: Use case-insensitive authentication like in UI (Andrey Krivko) - - Tie up loose ends with annotated tags: API & UI (Sean Edge) - - Return valid json for deleting branch via API (sponsored by O'Reilly Media) - - Expose username in project events API (sponsored by O'Reilly Media) - - Adds comments to commits in the API - - Performance improvements - - Fix post-receive issue for projects with deleted forks - - New gitlab-shell version with custom hooks support - - Improve code - - GitLab CI 5.2+ support (does not support older versions) - - Fixed bug when you can not push commits starting with 000000 to protected branches - - Added a password strength indicator - - Change project name and path in one form - - Display renamed files in diff views (Vinnie Okada) - - Fix raw view for public snippets - - Use secret token with GitLab internal API. - - Add missing timestamps to 'members' table - -v 7.4.5 - - Bump gitlab_git to 7.0.0.rc12 (includes Rugged 0.21.2) - -v 7.4.4 - - No changes - -v 7.4.3 - - Fix raw snippets view - - Fix security issue for member api - - Fix buildbox integration - -v 7.4.2 - - Fix internal snippet exposing for unauthenticated users - -v 7.4.1 - - Fix LDAP authentication for Git HTTP access - - Fix LDAP config lookup for provider 'ldap' - - Fix public snippets - - Fix 500 error on projects with nested submodules - -v 7.4.0 - - Refactored membership logic - - Improve error reporting on users API (Julien Bianchi) - - Refactor test coverage tools usage. Use SIMPLECOV=true to generate it locally - - Default branch is protected by default - - Increase unicorn timeout to 60 seconds - - Sort search autocomplete projects by stars count so most popular go first - - Add README to tab on project show page - - Do not delete tmp/repositories itself during clean-up, only its contents - - Support for backup uploads to remote storage - - Prevent notes polling when there are not notes - - Internal ForkService: Prepare support for fork to a given namespace - - API: Add support for forking a project via the API (Bernhard Kaindl) - - API: filter project issues by milestone (Julien Bianchi) - - Fail harder in the backup script - - Changes to Slack service structure, only webhook url needed - - Zen mode for wiki and milestones (Robert Schilling) - - Move Emoji parsing to html-pipeline-gitlab (Robert Schilling) - - Font Awesome 4.2 integration (Sullivan Senechal) - - Add Pushover service integration (Sullivan Senechal) - - Add select field type for services options (Sullivan Senechal) - - Add cross-project references to the Markdown parser (Vinnie Okada) - - Add task lists to issue and merge request descriptions (Vinnie Okada) - - Snippets can be public, internal or private - - Improve danger zone: ask project path to confirm data-loss action - - Raise exception on forgery - - Show build coverage in Merge Requests (requires GitLab CI v5.1) - - New milestone and label links on issue edit form - - Improved repository graphs - - Improve event note display in dashboard and project activity views (Vinnie Okada) - - Add users sorting to admin area - - UI improvements - - Fix ambiguous sha problem with mentioned commit - - Fixed bug with apostrophe when at mentioning users - - Add active directory ldap option - - Developers can push to wiki repo. Protected branches does not affect wiki repo any more - - Faster rev list - - Fix branch removal - -v 7.3.2 - - Fix creating new file via web editor - - Use gitlab-shell v2.0.1 - -v 7.3.1 - - Fix ref parsing in Gitlab::GitAccess - - Fix error 500 when viewing diff on a file with changed permissions - - Fix adding comments to MR when source branch is master - - Fix error 500 when searching description contains relative link - -v 7.3.0 - - Always set the 'origin' remote in satellite actions - - Write authorized_keys in tmp/ during tests - - Use sockets to connect to Redis - - Add dormant New Relic gem (can be enabled via environment variables) - - Expire Rack sessions after 1 week - - Cleaner signin/signup pages - - Improved comments UI - - Better search with filtering, pagination etc - - Added a checkbox to toggle line wrapping in diff (Yuriy Glukhov) - - Prevent project stars duplication when fork project - - Use the default Unicorn socket backlog value of 1024 - - Support Unix domain sockets for Redis - - Store session Redis keys in 'session:gitlab:' namespace - - Deprecate LDAP account takeover based on partial LDAP email / GitLab username match - - Use /bin/sh instead of Bash in bin/web, bin/background_jobs (Pavel Novitskiy) - - Keyboard shortcuts for productivity (Robert Schilling) - - API: filter issues by state (Julien Bianchi) - - API: filter issues by labels (Julien Bianchi) - - Add system hook for ssh key changes - - Add blob permalink link (Ciro Santilli) - - Create annotated tags through UI and API (Sean Edge) - - Snippets search (Charles Bushong) - - Comment new push to existing MR - - Add 'ci' to the blacklist of forbidden names - - Improve text filtering on issues page - - Comment & Close button - - Process git push --all much faster - - Don't allow edit of system notes - - Project wiki search (Ralf Seidler) - - Enabled Shibboleth authentication support (Matus Banas) - - Zen mode (fullscreen) for issues/MR/notes (Robert Schilling) - - Add ability to configure webhook timeout via gitlab.yml (Wes Gurney) - - Sort project merge requests in asc or desc order for updated_at or created_at field (sponsored by O'Reilly Media) - - Add Redis socket support to 'rake gitlab:shell:install' - -v 7.2.1 - - Delete orphaned labels during label migration (James Brooks) - - Security: prevent XSS with stricter MIME types for raw repo files - -v 7.2.0 - - Explore page - - Add project stars (Ciro Santilli) - - Log Sidekiq arguments - - Better labels: colors, ability to rename and remove - - Improve the way merge request collects diffs - - Improve compare page for large diffs - - Expose the full commit message via API - - Fix 500 error on repository rename - - Fix bug when MR download patch return invalid diff - - Test gitlab-shell integration - - Repository import timeout increased from 2 to 4 minutes allowing larger repos to be imported - - API for labels (Robert Schilling) - - API: ability to set an import url when creating project for specific user - -v 7.1.1 - - Fix cpu usage issue in Firefox - - Fix redirect loop when changing password by new user - - Fix 500 error on new merge request page - -v 7.1.0 - - Remove observers - - Improve MR discussions - - Filter by description on Issues#index page - - Fix bug with namespace select when create new project page - - Show README link after description for non-master members - - Add @all mention for comments - - Dont show reply button if user is not signed in - - Expose more information for issues with webhook - - Add a mention of the merge request into the default merge request commit message - - Improve code highlight, introduce support for more languages like Go, Clojure, Erlang etc - - Fix concurrency issue in repository download - - Dont allow repository name start with ? - - Improve email threading (Pierre de La Morinerie) - - Cleaner help page - - Group milestones - - Improved email notifications - - Contributors API (sponsored by Mobbr) - - Fix LDAP TLS authentication (Boris HUISGEN) - - Show VERSION information on project sidebar - - Improve branch removal logic when accept MR - - Fix bug where comment form is spawned inside the Reply button - - Remove Dir.chdir from Satellite#lock for thread-safety - - Increased default git max_size value from 5MB to 20MB in gitlab.yml. Please update your configs! - - Show error message in case of timeout in satellite when create MR - - Show first 100 files for huge diff instead of hiding all - - Change default admin email from admin@local.host to admin@example.com - -v 7.0.0 - - The CPU no longer overheats when you hold down the spacebar - - Improve edit file UI - - Add ability to upload group avatar when create - - Protected branch cannot be removed - - Developers can remove normal branches with UI - - Remove branch via API (sponsored by O'Reilly Media) - - Move protected branches page to Project settings area - - Redirect to Files view when create new branch via UI - - Drag and drop upload of image in every markdown-area (Earle Randolph Bunao and Neil Francis Calabroso) - - Refactor the markdown relative links processing - - Make it easier to implement other CI services for GitLab - - Group masters can create projects in group - - Deprecate ruby 1.9.3 support - - Only masters can rewrite/remove git tags - - Add X-Frame-Options SAMEORIGIN to Nginx config so Sidekiq admin is visible - - UI improvements - - Case-insensetive search for issues - - Update to rails 4.1 - - Improve performance of application for projects and groups with a lot of members - - Formally support Ruby 2.1 - - Include Nginx gitlab-ssl config - - Add manual language detection for highlight.js - - Added example.com/:username routing - - Show notice if your profile is public - - UI improvements for mobile devices - - Improve diff rendering performance - - Drag-n-drop for issues and merge requests between states at milestone page - - Fix '0 commits' message for huge repositories on project home page - - Prevent 500 error page when visit commit page from large repo - - Add notice about huge push over http to unicorn config - - File action in satellites uses default 30 seconds timeout instead of old 10 seconds one - - Overall performance improvements - - Skip init script check on omnibus-gitlab - - Be more selective when killing stray Sidekiqs - - Check LDAP user filter during sign-in - - Remove wall feature (no data loss - you can take it from database) - - Dont expose user emails via API unless you are admin - - Detect issues closed by Merge Request description - - Better email subject lines from email on push service (Alex Elman) - - Enable identicon for gravatar be default - -v 6.9.2 - - Revert the commit that broke the LDAP user filter - -v 6.9.1 - - Fix scroll to highlighted line - - Fix the pagination on load for commits page - -v 6.9.0 - - Store Rails cache data in the Redis `cache:gitlab` namespace - - Adjust MySQL limits for existing installations - - Add db index on project_id+iid column. This prevents duplicate on iid (During migration duplicates will be removed) - - Markdown preview or diff during editing via web editor (Evgeniy Sokovikov) - - Give the Rails cache its own Redis namespace - - Add ability to set different ssh host, if different from http/https - - Fix syntax highlighting for code comments blocks - - Improve comments loading logic - - Stop refreshing comments when the tab is hidden - - Improve issue and merge request mobile UI (Drew Blessing) - - Document how to convert a backup to PostgreSQL - - Fix locale bug in backup manager - - Fix can not automerge when MR description is too long - - Fix wiki backup skip bug - - Two Step MR creation process - - Remove unwanted files from satellite working directory with git clean -fdx - - Accept merge request via API (sponsored by O'Reilly Media) - - Add more access checks during API calls - - Block SSH access for 'disabled' Active Directory users - - Labels for merge requests (Drew Blessing) - - Threaded emails by setting a Message-ID (Philip Blatter) - -v 6.8.0 - - Ability to at mention users that are participating in issue and merge req. discussion - - Enabled GZip Compression for assets in example Nginx, make sure that Nginx is compiled with --with-http_gzip_static_module flag (this is default in Ubuntu) - - Make user search case-insensitive (Christopher Arnold) - - Remove omniauth-ldap nickname bug workaround - - Drop all tables before restoring a Postgres backup - - Make the repository downloads path configurable - - Create branches via API (sponsored by O'Reilly Media) - - Changed permission of gitlab-satellites directory not to be world accessible - - Protected branch does not allow force push - - Fix popen bug in `rake gitlab:satellites:create` - - Disable connection reaping for MySQL - - Allow oauth signup without email for twitter and github - - Fix faulty namespace names that caused 500 on user creation - - Option to disable standard login - - Clean old created archives from repository downloads directory - - Fix download link for huge MR diffs - - Expose event and mergerequest timestamps in API - - Fix emails on push service when only one commit is pushed - -v 6.7.3 - - Fix the merge notification email not being sent (Pierre de La Morinerie) - - Drop all tables before restoring a Postgres backup - - Remove yanked modernizr gem - -v 6.7.2 - - Fix upgrader script - -v 6.7.1 - - Fix GitLab CI integration - -v 6.7.0 - - Increased the example Nginx client_max_body_size from 5MB to 20MB, consider updating it manually on existing installations - - Add support for Gemnasium as a Project Service (Olivier Gonzalez) - - Add edit file button to MergeRequest diff - - Public groups (Jason Hollingsworth) - - Cleaner headers in Notification Emails (Pierre de La Morinerie) - - Blob and tree gfm links to anchors work - - Piwik Integration (Sebastian Winkler) - - Show contribution guide link for new issue form (Jeroen van Baarsen) - - Fix CI status for merge requests from fork - - Added option to remove issue assignee on project issue page and issue edit page (Jason Blanchard) - - New page load indicator that includes a spinner that scrolls with the page - - Converted all the help sections into markdown - - LDAP user filters - - Streamline the content of notification emails (Pierre de La Morinerie) - - Fixes a bug with group member administration (Matt DeTullio) - - Sort tag names using VersionSorter (Robert Speicher) - - Add GFM autocompletion for MergeRequests (Robert Speicher) - - Add webhook when a new tag is pushed (Jeroen van Baarsen) - - Add button for toggling inline comments in diff view - - Add retry feature for repository import - - Reuse the GitLab LDAP connection within each request - - Changed markdown new line behaviour to conform to markdown standards - - Fix global search - - Faster authorized_keys rebuilding in `rake gitlab:shell:setup` (requires gitlab-shell 1.8.5) - - Create and Update MR calls now support the description parameter (Greg Messner) - - Markdown relative links in the wiki link to wiki pages, markdown relative links in repositories link to files in the repository - - Added Slack service integration (Federico Ravasio) - - Better API responses for access_levels (sponsored by O'Reilly Media) - - Requires at least 2 unicorn workers - - Requires gitlab-shell v1.9+ - - Replaced gemoji(due to closed licencing problem) with Phantom Open Emoji library(combined SIL Open Font License, MIT License and the CC 3.0 License) - - Fix `/:username.keys` response content type (Dmitry Medvinsky) - -v 6.6.5 - - Added option to remove issue assignee on project issue page and issue edit page (Jason Blanchard) - - Hide mr close button for comment form if merge request was closed or inline comment - - Adds ability to reopen closed merge request - -v 6.6.4 - - Add missing html escape for highlighted code blocks in comments, issues - -v 6.6.3 - - Fix 500 error when edit yourself from admin area - - Hide private groups for public profiles - -v 6.6.2 - - Fix 500 error on branch/tag create or remove via UI - -v 6.6.1 - - Fix 500 error on files tab if submodules presents - -v 6.6.0 - - Retrieving user ssh keys publically(github style): http://__HOST__/__USERNAME__.keys - - Permissions: Developer now can manage issue tracker (modify any issue) - - Improve Code Compare page performance - - Group avatar - - Pygments.rb replaced with highlight.js - - Improve Merge request diff store logic - - Improve render performnace for MR show page - - Fixed Assembla hardcoded project name - - Jira integration documentation - - Refactored app/services - - Remove snippet expiration - - Mobile UI improvements (Drew Blessing) - - Fix block/remove UI for admin::users#show page - - Show users' group membership on users' activity page (Robert Djurasaj) - - User pages are visible without login if user is authorized to a public project - - Markdown rendered headers have id derived from their name and link to their id - - Improve application to work faster with large groups (100+ members) - - Multiple emails per user - - Show last commit for file when view file source - - Restyle Issue#show page and MR#show page - - Ability to filter by multiple labels for Issues page - - Rails version to 4.0.3 - - Fixed attachment identifier displaying underneath note text (Jason Blanchard) - -v 6.5.1 - - Fix branch selectbox when create merge request from fork - -v 6.5.0 - - Dropdown menus on issue#show page for assignee and milestone (Jason Blanchard) - - Add color custimization and previewing to broadcast messages - - Fixed notes anchors - - Load new comments in issues dynamically - - Added sort options to Public page - - New filters (assigned/authored/all) for Dashboard#issues/merge_requests (sponsored by Say Media) - - Add project visibility icons to dashboard - - Enable secure cookies if https used - - Protect users/confirmation with rack_attack - - Default HTTP headers to protect against MIME-sniffing, force https if enabled - - Bootstrap 3 with responsive UI - - New repository download formats: tar.bz2, zip, tar (Jason Hollingsworth) - - Restyled accept widgets for MR - - SCSS refactored - - Use jquery timeago plugin - - Fix 500 error for rdoc files - - Ability to customize merge commit message (sponsored by Say Media) - - Search autocomplete via ajax - - Add website url to user profile - - Files API supports base64 encoded content (sponsored by O'Reilly Media) - - Added support for Go's repository retrieval (Bruno Albuquerque) - -v 6.4.3 - - Don't use unicorn worker killer if PhusionPassenger is defined - -v 6.4.2 - - Fixed wrong behaviour of script/upgrade.rb - -v 6.4.1 - - Fixed bug with repository rename - - Fixed bug with project transfer - -v 6.4.0 - - Added sorting to project issues page (Jason Blanchard) - - Assembla integration (Carlos Paramio) - - Fixed another 500 error with submodules - - UI: More compact issues page - - Minimal password length increased to 8 symbols - - Side-by-side diff view (Steven Thonus) - - Internal projects (Jason Hollingsworth) - - Allow removal of avatar (Drew Blessing) - - Project webhooks now support issues and merge request events - - Visiting project page while not logged in will redirect to sign-in instead of 404 (Jason Hollingsworth) - - Expire event cache on avatar creation/removal (Drew Blessing) - - Archiving old projects (Steven Thonus) - - Rails 4 - - Add time ago tooltips to show actual date/time - - UI: Fixed UI for admin system hooks - - Ruby script for easier GitLab upgrade - - Do not remove Merge requests if fork project was removed - - Improve sign-in/signup UX - - Add resend confirmation link to sign-in page - - Set noreply@HOSTNAME for reply_to field in all emails - - Show GitLab API version on Admin#dashboard - - API Cross-origin resource sharing - - Show READMe link at project home page - - Show repo size for projects in Admin area - -v 6.3.0 - - API for adding gitlab-ci service - - Init script now waits for pids to appear after (re)starting before reporting status (Rovanion Luckey) - - Restyle project home page - - Grammar fixes - - Show branches list (which branches contains commit) on commit page (Andrew Kumanyaev) - - Security improvements - - Added support for GitLab CI 4.0 - - Fixed issue with 500 error when group did not exist - - Ability to leave project - - You can create file in repo using UI - - You can remove file from repo using UI - - API: dropped default_branch attribute from project during creation - - Project default_branch is not stored in db any more. It takes from repo now. - - Admin broadcast messages - - UI improvements - - Dont show last push widget if user removed this branch - - Fix 500 error for repos with newline in file name - - Extended html titles - - API: create/update/delete repo files - - Admin can transfer project to any namespace - - API: projects/all for admin users - - Fix recent branches order - -v 6.2.4 - - Security: Cast API private_token to string (CVE-2013-4580) - - Security: Require gitlab-shell 1.7.8 (CVE-2013-4581, CVE-2013-4582, CVE-2013-4583) - - Fix for Git SSH access for LDAP users - -v 6.2.3 - - Security: More protection against CVE-2013-4489 - - Security: Require gitlab-shell 1.7.4 (CVE-2013-4490, CVE-2013-4546) - - Fix sidekiq rake tasks - -v 6.2.2 - - Security: Update gitlab_git (CVE-2013-4489) - -v 6.2.1 - - Security: Fix issue with generated passwords for new users - -v 6.2.0 - - Public project pages are now visible to everyone (files, issues, wik, etc.) - THIS MEANS YOUR ISSUES AND WIKI FOR PUBLIC PROJECTS ARE PUBLICLY VISIBLE AFTER THE UPGRADE - - Add group access to permissions page - - Require current password to change one - - Group owner or admin can remove other group owners - - Remove group transfer since we have multiple owners - - Respect authorization in Repository API - - Improve UI for Project#files page - - Add more security specs - - Added search for projects by name to api (Izaak Alpert) - - Make default user theme configurable (Izaak Alpert) - - Update logic for validates_merge_request for tree of MR (Andrew Kumanyaev) - - Rake tasks for webhooks management (Jonhnny Weslley) - - Extended User API to expose admin and can_create_group for user creation/updating (Boyan Tabakov) - - API: Remove group - - API: Remove project - - Avatar upload on profile page with a maximum of 100KB (Steven Thonus) - - Store the sessions in Redis instead of the cookie store - - Fixed relative links in markdown - - User must confirm their email if signup enabled - - User must confirm changed email - -v 6.1.0 - - Project specific IDs for issues, mr, milestones - Above items will get a new id and for example all bookmarked issue urls will change. - Old issue urls are redirected to the new one if the issue id is too high for an internal id. - - Description field added to Merge Request - - API: Sudo api calls (Izaak Alpert) - - API: Group membership api (Izaak Alpert) - - Improved commit diff - - Improved large commit handling (Boyan Tabakov) - - Rewrite: Init script now less prone to errors and keeps better track of the service (Rovanion Luckey) - - Link issues, merge requests, and commits when they reference each other with GFM (Ash Wilson) - - Close issues automatically when pushing commits with a special message - - Improve user removal from admin area - - Invalidate events cache when project was moved - - Remove deprecated classes and rake tasks - - Add event filter for group and project show pages - - Add links to create branch/tag from project home page - - Add public-project? checkbox to new-project view - - Improved compare page. Added link to proceed into Merge Request - - Send an email to a user when they are added to group - - New landing page when you have 0 projects - -v 6.0.0 - - Feature: Replace teams with group membership - We introduce group membership in 6.0 as a replacement for teams. - The old combination of groups and teams was confusing for a lot of people. - And when the members of a team where changed this wasn't reflected in the project permissions. - In GitLab 6.0 you will be able to add members to a group with a permission level for each member. - These group members will have access to the projects in that group. - Any changes to group members will immediately be reflected in the project permissions. - You can even have multiple owners for a group, greatly simplifying administration. - - Feature: Ability to have multiple owners for group - - Feature: Merge Requests between fork and project (Izaak Alpert) - - Feature: Generate fingerprint for ssh keys - - Feature: Ability to create and remove branches with UI - - Feature: Ability to create and remove git tags with UI - - Feature: Groups page in profile. You can leave group there - - API: Allow login with LDAP credentials - - Redesign: project settings navigation - - Redesign: snippets area - - Redesign: ssh keys page - - Redesign: buttons, blocks and other ui elements - - Add comment title to rss feed - - You can use arrows to navigate at tree view - - Add project filter on dashboard - - Cache project graph - - Drop support of root namespaces - - Default theme is classic now - - Cache result of methods like authorize_projects, project.team.members etc - - Remove $.ready events - - Fix onclick events being double binded - - Add notification level to group membership - - Move all project controllers/views under Projects:: module - - Move all profile controllers/views under Profiles:: module - - Apply user project limit only for personal projects - - Unicorn is default web server again - - Store satellites lock files inside satellites dir - - Disabled threadsafety mode in rails - - Fixed bug with loosing MR comments - - Improved MR comments logic - - Render readme file for projects in public area - -v 5.4.2 - - Security: Cast API private_token to string (CVE-2013-4580) - - Security: Require gitlab-shell 1.7.8 (CVE-2013-4581, CVE-2013-4582, CVE-2013-4583) - -v 5.4.1 - - Security: Fixes for CVE-2013-4489 - - Security: Require gitlab-shell 1.7.4 (CVE-2013-4490, CVE-2013-4546) - -v 5.4.0 - - Ability to edit own comments - - Documentation improvements - - Improve dashboard projects page - - Fixed nav for empty repos - - GitLab Markdown help page - - Misspelling fixes - - Added support of unicorn and fog gems - - Added client list to API doc - - Fix PostgreSQL database restoration problem - - Increase snippet content column size - - allow project import via git:// url - - Show participants on issues, including mentions - - Notify mentioned users with email - -v 5.3.0 - - Refactored services - - Campfire service added - - HipChat service added - - Fixed bug with LDAP + git over http - - Fixed bug with google analytics code being ignored - - Improve sign-in page if ldap enabled - - Respect newlines in wall messages - - Generate the Rails secret token on first run - - Rename repo feature - - Init.d: remove gitlab.socket on service start - - Api: added teams api - - Api: Prevent blob content being escaped - - Api: Smart deploy key add behaviour - - Api: projects/owned.json return user owned project - - Fix bug with team assignation on project from #4109 - - Advanced snippets: public/private, project/personal (Andrew Kulakov) - - Repository Graphs (Karlo Nicholas T. Soriano) - - Fix dashboard lost if comment on commit - - Update gitlab-grack. Fixes issue with --depth option - - Fix project events duplicate on project page - - Fix postgres error when displaying network graph. - - Fix dashboard event filter when navigate via turbolinks - - init.d: Ensure socket is removed before starting service - - Admin area: Style teams:index, group:show pages - - Own page for failed forking - - Scrum view for milestone - -v 5.2.0 - - Turbolinks - - Git over http with ldap credentials - - Diff with better colors and some spacing on the corners - - Default values for project features - - Fixed huge_commit view - - Restyle project clone panel - - Move Gitlab::Git code to gitlab_git gem - - Move update docs in repo - - Requires gitlab-shell v1.4.0 - - Fixed submodules listing under file tab - - Fork feature (Angus MacArthur) - - git version check in gitlab:check - - Shared deploy keys feature - - Ability to generate default labels set for issues - - Improve gfm autocomplete (Harold Luo) - - Added support for Google Analytics - - Code search feature (Javier Castro) - -v 5.1.0 - - You can login with email or username now - - Corrected project transfer rollback when repository cannot be moved - - Move both repo and wiki when project transfer requested - - Admin area: project editing was removed from admin namespace - - Access: admin user has now access to any project. - - Notification settings - - Gitlab::Git set of objects to abstract from grit library - - Replace Unicorn web server with Puma - - Backup/Restore refactored. Backup dump project wiki too now - - Restyled Issues list. Show milestone version in issue row - - Restyled Merge Request list - - Backup now dump/restore uploads - - Improved performance of dashboard (Andrew Kumanyaev) - - File history now tracks renames (Akzhan Abdulin) - - Drop wiki migration tools - - Drop sqlite migration tools - - project tagging - - Paginate users in API - - Restyled network graph (Hiroyuki Sato) - -v 5.0.1 - - Fixed issue with gitlab-grit being overridden by grit - -v 5.0.0 - - Replaced gitolite with gitlab-shell - - Removed gitolite-related libraries - - State machine added - - Setup gitlab as git user - - Internal API - - Show team tab for empty projects - - Import repository feature - - Updated rails - - Use lambda for scopes - - Redesign admin area -> users - - Redesign admin area -> user - - Secure link to file attachments - - Add validations for Group and Team names - - Restyle team page for project - - Update capybara, rspec-rails, poltergeist to recent versions - - Wiki on git using Gollum - - Added Solarized Dark theme for code review - - Don't show user emails in autocomplete lists, profile pages - - Added settings tab for group, team, project - - Replace user popup with icons in header - - Handle project moving with gitlab-shell - - Added select2-rails for selectboxes with ajax data load - - Fixed search field on projects page - - Added teams to search autocomplete - - Move groups and teams on dashboard sidebar to sub-tabs - - API: improved return codes and docs. (Felix Gilcher, Sebastian Ziebell) - - Redesign wall to be more like chat - - Snippets, Wall features are disabled by default for new projects - -v 4.2.0 - - Teams - - User show page. Via /u/username - - Show help contents on pages for better navigation - - Async gitolite calls - - added satellites logs - - can_create_group, can_create_team booleans for User - - Process webhooks async - - GFM: Fix images escaped inside links - - Network graph improved - - Switchable branches for network graph - - API: Groups - - Fixed project download - -v 4.1.0 - - Optional Sign-Up - - Discussions - - Satellites outside of tmp - - Line numbers for blame - - Project public mode - - Public area with unauthorized access - - Load dashboard events with ajax - - remember dashboard filter in cookies - - replace resque with sidekiq - - fix routing issues - - cleanup rake tasks - - fix backup/restore - - scss cleanup - - show preview for note images - - improved network-graph - - get rid of app/roles/ - - added new classes Team, Repository - - Reduce amount of gitolite calls - - Ability to add user in all group projects - - remove deprecated configs - - replaced Korolev font with open font - - restyled admin/dashboard page - - restyled admin/projects page - -v 4.0.0 - - Remove project code and path from API. Use id instead - - Return valid cloneable url to repo for webhook - - Fixed backup issue - - Reorganized settings - - Fixed commits compare - - Refactored scss - - Improve status checks - - Validates presence of User#name - - Fixed postgres support - - Removed sqlite support - - Modified post-receive hook - - Milestones can be closed now - - Show comment events on dashboard - - Quick add team members via group#people page - - [API] expose created date for hooks and SSH keys - - [API] list, create issue notes - - [API] list, create snippet notes - - [API] list, create wall notes - - Remove project code - use path instead - - added username field to user - - rake task to fill usernames based on emails create namespaces for users - - STI Group < Namespace - - Project has namespace_id - - Projects with namespaces also namespaced in gitolite and stored in subdir - - Moving project to group will move it under group namespace - - Ability to move project from namespaces to another - - Fixes commit patches getting escaped (see #2036) - - Support diff and patch generation for commits and merge request - - MergeReqest doesn't generate a temporary file for the patch any more - - Update the UI to allow downloading Patch or Diff - -v 3.1.0 - - Updated gems - - Services: Gitlab CI integration - - Events filter on dashboard - - Own namespace for redis/resque - - Optimized commit diff views - - add alphabetical order for projects admin page - - Improved web editor - - Commit stats page - - Documentation split and cleanup - - Link to commit authors everywhere - - Restyled milestones list - - added Milestone to Merge Request - - Restyled Top panel - - Refactored Satellite Code - - Added file line links - - moved from capybara-webkit to poltergeist + phantomjs - -v 3.0.3 - - Fixed bug with issues list in Chrome - - New Feature: Import team from another project - -v 3.0.2 - - Fixed gitlab:app:setup - - Fixed application error on empty project in admin area - - Restyled last push widget - -v 3.0.1 - - Fixed git over http - -v 3.0.0 - - Projects groups - - Web Editor - - Fixed bug with gitolite keys - - UI improved - - Increased performance of application - - Show user avatar in last commit when browsing Files - - Refactored Gitlab::Merge - - Use Font Awesome for icons - - Separate observing of Note and MergeRequests - - Milestone "All Issues" filter - - Fix issue close and reopen button text and styles - - Fix forward/back while browsing Tree hierarchy - - Show number of notes for commits and merge requests - - Added support pg from box and update installation doc - - Reject ssh keys that break gitolite - - [API] list one project hook - - [API] edit project hook - - [API] list project snippets - - [API] allow to authorize using private token in HTTP header - - [API] add user creation - -v 2.9.1 - - Fixed resque custom config init - -v 2.9.0 - - fixed inline notes bugs - - refactored rspecs - - refactored gitolite backend - - added factory_girl - - restyled projects list on dashboard - - ssh keys validation to prevent gitolite crash - - send notifications if changed permission in project - - scss refactoring. gitlab_bootstrap/ dir - - fix git push http body bigger than 112k problem - - list of labels page under issues tab - - API for milestones, keys - - restyled buttons - - OAuth - - Comment order changed - -v 2.8.1 - - ability to disable gravatars - - improved MR diff logic - - ssh key help page - -v 2.8.0 - - Gitlab Flavored Markdown - - Bulk issues update - - Issues API - - Cucumber coverage increased - - Post-receive files fixed - - UI improved - - Application cleanup - - more cucumber - - capybara-webkit + headless - -v 2.7.0 - - Issue Labels - - Inline diff - - Git HTTP - - API - - UI improved - - System hooks - - UI improved - - Dashboard events endless scroll - - Source performance increased - -v 2.6.0 - - UI polished - - Improved network graph + keyboard nav - - Handle huge commits - - Last Push widget - - Bugfix - - Better performance - - Email in resque - - Increased test coverage - - Ability to remove branch with MR accept - - a lot of code refactored - -v 2.5.0 - - UI polished - - Git blame for file - - Bugfix - - Email in resque - - Better test coverage - -v 2.4.0 - - Admin area stats page - - Ability to block user - - Simplified dashboard area - - Improved admin area - - Bootstrap 2.0 - - Responsive layout - - Big commits handling - - Performance improved - - Milestones - -v 2.3.1 - - Issues pagination - - ssl fixes - - Merge Request pagination - -v 2.3.0 - - Dashboard r1 - - Search r1 - - Project page - - Close merge request on push - - Persist MR diff after merge - - mysql support - - Documentation - -v 2.2.0 - - We’ve added support of LDAP auth - - Improved permission logic (4 roles system) - - Protected branches (now only masters can push to protected branches) - - Usability improved - - twitter bootstrap integrated - - compare view between commits - - wiki feature - - now you can enable/disable issues, wiki, wall features per project - - security fixes - - improved code browsing (ajax branch switch etc) - - improved per-line commenting - - git submodules displayed - - moved to rails 3.2 - - help section improved - -v 2.1.0 - - Project tab r1 - - List branches/tags - - per line comments - - mass user import - -v 2.0.0 - - gitolite as main git host system - - merge requests - - project/repo access - - link to commit/issue feed - - design tab - - improved email notifications - - restyled dashboard - - bugfix - -v 1.2.2 - - common config file gitlab.yml - - issues restyle - - snippets restyle - - clickable news feed header on dashboard - - bugfix - -v 1.2.1 - - bugfix - -v 1.2.0 - - new design - - user dashboard - - network graph - - markdown support for comments - - encoding issues - - wall like twitter timeline - -v 1.1.0 - - project dashboard - - wall redesigned - - feature: code snippets - - fixed horizontal scroll on file preview - - fixed app crash if commit message has invalid chars - - bugfix & code cleaning - -v 1.0.2 - - fixed bug with empty project - - added adv validation for project path & code - - feature: issues can be sortable - - bugfix - - username displayed on top panel - -v 1.0.1 - - fixed: with invalid source code for commit - - fixed: lose branch/tag selection when use tree navigation - - when history clicked - display path - - bug fix & code cleaning - -v 1.0.0 - - bug fix - - projects preview mode - -v 0.9.6 - - css fix - - new repo empty tree until restart server - fixed - -v 0.9.4 - - security improved - - authorization improved - - html escaping - - bug fix - - increased test coverage - - design improvements - -v 0.9.1 - - increased test coverage - - design improvements - - new issue email notification - - updated app name - - issue redesigned - - issue can be edit - -v 0.8.0 - - syntax highlight for main file types - - redesign - - stability - - security fixes - - increased test coverage - - email notification +v 7.14.3 through 0.8.0 + - See changelogs/archive.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a885e706810..0cdcb54b0ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,19 +91,7 @@ This was inspired by [an article by Kent C. Dodds][medium-up-for-grabs]. ## Implement design & UI elements -### Design reference - -The GitLab design reference can be found in the [gitlab-design] project. -The designs are made using Antetype (`.atype` files). You can use the -[free Antetype viewer (Mac OSX only)] or grab an exported PNG from the design -(the PNG is 1:1). - -The current designs can be found in the [`gitlab8.atype` file]. - -### UI development kit - -Implemented UI elements can also be found at https://gitlab.com/help/ui. Please -note that this page isn't comprehensive at this time. +Please see the [UI Guide for building GitLab]. ## Issue tracker @@ -129,7 +117,7 @@ request that potentially fixes it. ### Feature proposals -To create a feature proposal for CE and CI, open an issue on the +To create a feature proposal for CE, open an issue on the [issue tracker of CE][ce-tracker]. For feature proposals for EE, open an issue on the @@ -144,16 +132,7 @@ code snippet right after your description in a new line: `~"feature proposal"`. Please keep feature proposals as small and simple as possible, complex ones might be edited to make them small and simple. -You are encouraged to use the template below for feature proposals. - -``` -## Description -Include problem, use cases, benefits, and/or goals - -## Proposal - -## Links / references -``` +Please submit Feature Proposals using the ['Feature Proposal' issue template](.gitlab/issue_templates/Feature Proposal.md) provided on the issue tracker. For changes in the interface, it can be helpful to create a mockup first. If you want to create something yourself, consider opening an issue first to @@ -166,55 +145,11 @@ submitting your own, there's a good chance somebody else had the same issue or feature proposal. Show your support with an award emoji and/or join the discussion. -Please submit bugs using the following template in the issue description area. +Please submit bugs using the ['Bug' issue template](.gitlab/issue_templates/Bug.md) provided on the issue tracker. The text in the parenthesis is there to help you with what to include. Omit it when submitting the actual issue. You can copy-paste it and then edit as you see fit. -``` -## Summary - -(Summarize your issue in one sentence - what goes wrong, what did you expect to happen) - -## Steps to reproduce - -(How one can reproduce the issue - this is very important) - -## Expected behavior - -(What you should see instead) - -## Relevant logs and/or screenshots - -(Paste any relevant logs - please use code blocks (```) to format console output, -logs, and code as it's very hard to read otherwise.) - -## Output of checks - -### Results of GitLab Application Check - -(For installations with omnibus-gitlab package run and paste the output of: -sudo gitlab-rake gitlab:check SANITIZE=true) - -(For installations from source run and paste the output of: -sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production SANITIZE=true) - -(we will only investigate if the tests are passing) - -### Results of GitLab Environment Info - -(For installations with omnibus-gitlab package run and paste the output of: -sudo gitlab-rake gitlab:env:info) - -(For installations from source run and paste the output of: -sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production) - -## Possible fixes - -(If you can, link to the line of code that might be responsible for the problem) - -``` - ### Issue weight Issue weight allows us to get an idea of the amount of work required to solve @@ -291,8 +226,7 @@ a feedback issue (if there isn't one already) and leave a comment asking for it to be marked as `Accepting merge requests`. Please include screenshots or wireframes if the feature will also change the UI. -Merge requests can be filed either at [GitLab.com][gitlab-mr-tracker] or at -[github.com][github-mr-tracker]. +Merge requests should be opened at [GitLab.com][gitlab-mr-tracker]. If you are new to GitLab development (or web development in general), see the [I want to contribute!](#i-want-to-contribute) section to get you started with @@ -311,10 +245,17 @@ tests are least likely to receive timely feedback. The workflow to make a merge request is as follows: 1. Fork the project into your personal space on GitLab.com -1. Create a feature branch, branch away from `master`. +1. Create a feature branch, branch away from `master` 1. Write [tests](https://gitlab.com/gitlab-org/gitlab-development-kit#running-the-tests) and code -1. Add your changes to the [CHANGELOG](CHANGELOG) -1. If you are writing documentation, make sure to read the [documentation styleguide][doc-styleguide] +1. Add your changes to the [CHANGELOG](CHANGELOG): + 1. If you are fixing a ~regression issue, you can add your entry to the next + patch release (e.g. `8.12.5` if current version is `8.12.4`) + 1. Otherwise, add your entry to the next minor release (e.g. `8.13.0` if + current version is `8.12.4` + 1. Please add your entry at a random place among the entries of the targeted + release +1. If you are writing documentation, make sure to follow the + [documentation styleguide][doc-styleguide] 1. If you have multiple commits please combine them into one commit by [squashing them][git-squash] 1. Push the commit(s) to your fork @@ -323,7 +264,7 @@ request is as follows: 1. The MR description should give a motive for your change and the method you used to achieve it, see the [merge request description format] (#merge-request-description-format) -1. If the MR changes the UI it should include before and after screenshots +1. If the MR changes the UI it should include *Before* and *After* screenshots 1. If the MR changes CSS classes please include the list of affected pages, `grep css-class ./app -R` 1. Link any relevant [issues][ce-tracker] in the merge request description and @@ -335,7 +276,17 @@ request is as follows: [shell command guidelines](doc/development/shell_commands.md) 1. If your code creates new files on disk please read the [shared files guidelines](doc/development/shared_files.md). -1. When writing commit messages please follow [these](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) [guidelines](http://chris.beams.io/posts/git-commit/). +1. When writing commit messages please follow + [these](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) + [guidelines](http://chris.beams.io/posts/git-commit/). +1. If your merge request adds one or more migrations, make sure to execute all + migrations on a fresh database before the MR is reviewed. If the review leads + to large changes in the MR, do this again once the review is complete. +1. For more complex migrations, write tests. +1. Merge requests **must** adhere to the [merge request performance + guidelines](doc/development/merge_request_performance_guidelines.md). +1. For tests that use Capybara or PhantomJS, see this [article on how + to write reliable asynchronous tests](https://robots.thoughtbot.com/write-reliable-asynchronous-integration-tests-with-capybara). The **official merge window** is in the beginning of the month from the 1st to the 7th day of the month. This is the best time to submit an MR and get @@ -362,35 +313,19 @@ Please ensure that your merge request meets the contribution acceptance criteria When having your code reviewed and when reviewing merge requests please take the [code review guidelines](doc/development/code_review.md) into account. -### Merge request description format - -Please submit merge requests using the following template in the merge request -description area. Copy-paste it to retain the markdown format. - -``` -## What does this MR do? - -## Are there points in the code the reviewer needs to double check? - -## Why was this MR needed? - -## What are the relevant issue numbers? - -## Screenshots (if relevant) -``` - ### Contribution acceptance criteria 1. The change is as small as possible 1. Include proper tests and make all tests pass (unless it contains a test - exposing a bug in existing code) + exposing a bug in existing code). Every new class should have corresponding + unit tests, even if the class is exercised at a higher level, such as a feature test. 1. If you suspect a failing CI build is unrelated to your contribution, you may try and restart the failing CI job or ask a developer to fix the aforementioned failing test 1. Your MR initially contains a single commit (please use `git rebase -i` to squash commits) -1. Your changes can merge without problems (if not please merge `master`, never - rebase commits pushed to the remote server) +1. Your changes can merge without problems (if not please rebase if you're the + only one working on your feature branch, otherwise, merge `master`) 1. Does not break any existing functionality 1. Fixes one specific issue or implements one specific feature (do not combine things, send separate merge requests if needed) @@ -408,7 +343,10 @@ description area. Copy-paste it to retain the markdown format. entire line to follow it. This prevents linting tools from generating warnings. - Don't touch neighbouring lines. As an exception, automatic mass refactoring modifications may leave style non-compliant. -1. If the merge request adds any new libraries (gems, JavaScript libraries, etc.), they should conform to our [Licensing guidelines][license-finder-doc]. See the instructions in that document for help if your MR fails the "license-finder" test with a "Dependencies that need approval" error. +1. If the merge request adds any new libraries (gems, JavaScript libraries, + etc.), they should conform to our [Licensing guidelines][license-finder-doc]. + See the instructions in that document for help if your MR fails the + "license-finder" test with a "Dependencies that need approval" error. ## Changes for Stable Releases @@ -461,8 +399,10 @@ merge request: - multi-line method chaining style **Option B**: dot `.` on previous line - string literal quoting style **Option A**: single quoted by default 1. [Rails](https://github.com/bbatsov/rails-style-guide) +1. [Newlines styleguide][newlines-styleguide] 1. [Testing](doc/development/testing.md) -1. [CoffeeScript](https://github.com/thoughtbot/guides/tree/master/style/coffeescript) +1. [JavaScript (ES6)](https://github.com/airbnb/javascript) +1. [JavaScript (ES5)](https://github.com/airbnb/javascript/tree/master/es5) 1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab contributors to enhance security @@ -522,7 +462,6 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [accepting-mrs-ce]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name=Accepting+Merge+Requests [accepting-mrs-ee]: https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name=Accepting+Merge+Requests [gitlab-mr-tracker]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests -[github-mr-tracker]: https://github.com/gitlabhq/gitlabhq/pulls [gdk]: https://gitlab.com/gitlab-org/gitlab-development-kit [git-squash]: https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits [closed-merge-requests]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests?assignee_id=&label_name=&milestone_id=&scope=&sort=&state=closed @@ -532,7 +471,6 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming [doc-styleguide]: doc/development/doc_styleguide.md "Documentation styleguide" [scss-styleguide]: doc/development/scss_styleguide.md "SCSS styleguide" -[gitlab-design]: https://gitlab.com/gitlab-org/gitlab-design -[free Antetype viewer (Mac OSX only)]: https://itunes.apple.com/us/app/antetype-viewer/id824152298?mt=12 -[`gitlab8.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/current/ +[newlines-styleguide]: doc/development/newlines_styleguide.md "Newlines styleguide" +[UI Guide for building GitLab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/ui_guide.md [license-finder-doc]: doc/development/licensing.md diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index e4604e3afd0..4f2c1d15f6d 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -3.2.1 +3.6.6 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index e7c7d3cc3c8..b60d71966ae 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -0.7.8 +0.8.4 @@ -1,15 +1,13 @@ source 'https://rubygems.org' -gem 'rails', '4.2.7' +gem 'rails', '4.2.7.1' gem 'rails-deprecated_sanitizer', '~> 1.0.3' # Responders respond_to and respond_with gem 'responders', '~> 2.0' -# Specify a sprockets version due to increased performance -# See https://gitlab.com/gitlab-org/gitlab-ce/issues/6069 -gem 'sprockets', '~> 3.6.0' -gem 'sprockets-es6' +gem 'sprockets', '~> 3.7.0' +gem 'sprockets-es6', '~> 0.9.2' # Default values for AR models gem 'default_value_for', '~> 3.0.0' @@ -19,14 +17,14 @@ gem 'mysql2', '~> 0.3.16', group: :mysql gem 'pg', '~> 0.18.2', group: :postgres # Authentication libraries -gem 'devise', '~> 4.0' -gem 'doorkeeper', '~> 4.0' +gem 'devise', '~> 4.2' +gem 'doorkeeper', '~> 4.2.0' gem 'omniauth', '~> 1.3.1' gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-bitbucket', '~> 0.0.2' gem 'omniauth-cas3', '~> 1.1.2' -gem 'omniauth-facebook', '~> 3.0.0' +gem 'omniauth-facebook', '~> 4.0.0' gem 'omniauth-github', '~> 1.1.1' gem 'omniauth-gitlab', '~> 1.0.0' gem 'omniauth-google-oauth2', '~> 0.4.1' @@ -53,7 +51,7 @@ gem 'browser', '~> 2.2' # Extracting information from a git repository # Provide access to Gitlab::Git library -gem 'gitlab_git', '~> 10.4.3' +gem 'gitlab_git', '~> 10.6.8' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes @@ -69,7 +67,7 @@ gem 'gollum-rugged_adapter', '~> 0.4.2', require: false gem 'github-linguist', '~> 4.7.0', require: 'linguist' # API -gem 'grape', '~> 0.13.0' +gem 'grape', '~> 0.15.0' gem 'grape-entity', '~> 0.4.2' gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' @@ -77,7 +75,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' gem 'kaminari', '~> 0.17.0' # HAML -gem 'hamlit', '~> 2.5' +gem 'hamlit', '~> 2.6.1' # Files attachments gem 'carrierwave', '~> 0.10.0' @@ -97,24 +95,22 @@ gem 'fog-rackspace', '~> 0.1.1' # for aws storage gem 'unf', '~> 0.1.4' -# Authorization -gem 'six', '~> 0.2.0' - # Seed data gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing -gem 'html-pipeline', '~> 1.11.0' -gem 'task_list', '~> 1.0.2', require: 'task_list/railtie' -gem 'github-markup', '~> 1.4' -gem 'redcarpet', '~> 3.3.3' -gem 'RedCloth', '~> 4.3.2' -gem 'rdoc', '~>3.6' -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 'html-pipeline', '~> 1.11.0' +gem 'deckar01-task_list', '1.0.5', require: 'task_list/railtie' +gem 'gitlab-markup', '~> 1.5.0' +gem 'redcarpet', '~> 3.3.3' +gem 'RedCloth', '~> 4.3.2' +gem 'rdoc', '~>3.6' +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' # See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s # and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM @@ -125,8 +121,8 @@ gem 'diffy', '~> 3.0.3' # Application server group :unicorn do - gem 'unicorn', '~> 4.9.0' - gem 'unicorn-worker-killer', '~> 0.4.2' + gem 'unicorn', '~> 5.1.0' + gem 'unicorn-worker-killer', '~> 0.4.4' end # State machine @@ -135,11 +131,10 @@ gem 'state_machines-activerecord', '~> 0.4.0' gem 'after_commit_queue', '~> 1.3.0' # Issue tags -gem 'acts-as-taggable-on', '~> 3.4' +gem 'acts-as-taggable-on', '~> 4.0' # Background jobs -gem 'sinatra', '~> 1.4.4', require: false -gem 'sidekiq', '~> 4.0' +gem 'sidekiq', '~> 4.2' gem 'sidekiq-cron', '~> 0.4.0' gem 'redis-namespace', '~> 1.5.2' @@ -154,7 +149,7 @@ gem 'settingslogic', '~> 2.0.9' # Misc -gem 'version_sorter', '~> 2.0.0' +gem 'version_sorter', '~> 2.1.0' # Cache gem 'redis-rails', '~> 4.0.0' @@ -163,9 +158,6 @@ gem 'redis-rails', '~> 4.0.0' gem 'redis', '~> 3.2' gem 'connection_pool', '~> 2.0' -# Campfire integration -gem 'tinder', '~> 1.10.0' - # HipChat integration gem 'hipchat', '~> 1.5.0' @@ -204,7 +196,7 @@ gem 'licensee', '~> 8.0.0' gem 'rack-attack', '~> 4.3.1' # Ace editor -gem 'ace-rails-ap', '~> 4.0.2' +gem 'ace-rails-ap', '~> 4.1.0' # Keyboard shortcuts gem 'mousetrap-rails', '~> 1.4.6' @@ -212,10 +204,14 @@ gem 'mousetrap-rails', '~> 1.4.6' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.3' -# Parse duration +# Faster JSON +gem 'oj', '~> 2.17.4' + +# Parse time & duration +gem 'chronic', '~> 0.10.2' gem 'chronic_duration', '~> 0.10.6' -gem 'sass-rails', '~> 5.0.0' +gem 'sass-rails', '~> 5.0.6' gem 'coffee-rails', '~> 4.1.0' gem 'uglifier', '~> 2.7.2' gem 'turbolinks', '~> 2.5.0' @@ -229,14 +225,14 @@ gem 'gon', '~> 6.1.0' gem 'jquery-atwho-rails', '~> 1.3.2' gem 'jquery-rails', '~> 4.1.0' gem 'jquery-ui-rails', '~> 5.0.0' -gem 'request_store', '~> 1.3.0' +gem 'request_store', '~> 1.3' gem 'select2-rails', '~> 3.5.9' gem 'virtus', '~> 1.0.1' gem 'net-ssh', '~> 3.0.1' gem 'base32', '~> 0.3.0' # Sentry integration -gem 'sentry-raven', '~> 1.1.0' +gem 'sentry-raven', '~> 2.0.0' gem 'premailer-rails', '~> 1.9.0' @@ -266,6 +262,8 @@ group :development do # thin instead webrick gem 'thin', '~> 1.7.0' + + gem 'activerecord_sane_schema_dumper', '0.2' end group :development, :test do @@ -300,11 +298,11 @@ group :development, :test do gem 'spring-commands-spinach', '~> 1.1.0' gem 'spring-commands-teaspoon', '~> 0.0.2' - gem 'rubocop', '~> 0.41.2', require: false + gem 'rubocop', '~> 0.43.0', require: false gem 'rubocop-rspec', '~> 1.5.0', 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 - gem 'flog', '~> 4.3.2', require: false gem 'flay', '~> 2.6.1', require: false gem 'bundler-audit', '~> 0.5.0', require: false @@ -317,24 +315,22 @@ end group :test do gem 'shoulda-matchers', '~> 2.8.0', require: false gem 'email_spec', '~> 1.6.0' + gem 'json-schema', '~> 2.6.2' gem 'webmock', '~> 1.21.0' gem 'test_after_commit', '~> 0.4.2' gem 'sham_rack', '~> 1.3.6' + gem 'timecop', '~> 0.8.0' end -group :production do - gem 'gitlab_meta', '7.0' -end - -gem 'newrelic_rpm', '~> 3.14' +gem 'newrelic_rpm', '~> 3.16' gem 'octokit', '~> 4.3.0' -gem 'mail_room', '~> 0.8' +gem 'mail_room', '~> 0.8.1' gem 'email_reply_parser', '~> 0.5.8' -gem 'ruby-prof', '~> 0.15.9' +gem 'ruby-prof', '~> 0.16.2' ## CI gem 'activerecord-session_store', '~> 1.0.0' @@ -347,8 +343,8 @@ gem 'oauth2', '~> 1.2.0' gem 'paranoia', '~> 2.0' # Health check -gem 'health_check', '~> 2.1.0' +gem 'health_check', '~> 2.2.0' # System information -gem 'vmstat', '~> 2.1.1' +gem 'vmstat', '~> 2.2' gem 'sys-filesystem', '~> 1.1.6' diff --git a/Gemfile.lock b/Gemfile.lock index 866f5014847..a9892d1c130 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,35 +2,35 @@ GEM remote: https://rubygems.org/ specs: RedCloth (4.3.2) - ace-rails-ap (4.0.2) - actionmailer (4.2.7) - actionpack (= 4.2.7) - actionview (= 4.2.7) - activejob (= 4.2.7) + ace-rails-ap (4.1.0) + actionmailer (4.2.7.1) + actionpack (= 4.2.7.1) + actionview (= 4.2.7.1) + activejob (= 4.2.7.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.7) - actionview (= 4.2.7) - activesupport (= 4.2.7) + actionpack (4.2.7.1) + actionview (= 4.2.7.1) + activesupport (= 4.2.7.1) rack (~> 1.6) rack-test (~> 0.6.2) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.7) - activesupport (= 4.2.7) + actionview (4.2.7.1) + activesupport (= 4.2.7.1) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - activejob (4.2.7) - activesupport (= 4.2.7) + activejob (4.2.7.1) + activesupport (= 4.2.7.1) globalid (>= 0.3.0) - activemodel (4.2.7) - activesupport (= 4.2.7) + activemodel (4.2.7.1) + activesupport (= 4.2.7.1) builder (~> 3.1) - activerecord (4.2.7) - activemodel (= 4.2.7) - activesupport (= 4.2.7) + activerecord (4.2.7.1) + activemodel (= 4.2.7.1) + activesupport (= 4.2.7.1) arel (~> 6.0) activerecord-session_store (1.0.0) actionpack (>= 4.0, < 5.1) @@ -38,14 +38,16 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 1.5.2, < 3) railties (>= 4.0, < 5.1) - activesupport (4.2.7) + activerecord_sane_schema_dumper (0.2) + rails (>= 4, < 5) + activesupport (4.2.7.1) i18n (~> 0.7) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - acts-as-taggable-on (3.5.0) - activerecord (>= 3.2, < 5) + acts-as-taggable-on (4.0.0) + activerecord (>= 4.0) addressable (2.3.8) after_commit_queue (1.3.0) activerecord (>= 3.0) @@ -128,6 +130,7 @@ GEM mime-types (>= 1.16) cause (0.1) charlock_holmes (0.7.3) + chronic (0.10.2) chronic_duration (0.10.6) numerizer (~> 0.1.1) chunky_png (1.3.5) @@ -156,11 +159,15 @@ GEM database_cleaner (1.5.3) debug_inspector (0.0.2) debugger-ruby_core_source (1.3.8) + deckar01-task_list (1.0.5) + activesupport (~> 4.0) + html-pipeline + rack (~> 1.0) default_value_for (3.0.2) activerecord (>= 3.2.0, < 5.1) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) - devise (4.1.1) + devise (4.2.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0, < 5.1) @@ -175,7 +182,7 @@ GEM diff-lcs (1.2.5) diffy (3.0.7) docile (1.1.5) - doorkeeper (4.0.0) + doorkeeper (4.2.0) railties (>= 4.2) dropzonejs-rails (0.7.2) rails (> 3.1) @@ -188,7 +195,7 @@ GEM erubis (2.7.0) escape_utils (1.1.1) eventmachine (1.0.8) - excon (0.49.0) + excon (0.52.0) execjs (2.6.0) expression_parser (0.9.0) factory_girl (4.5.0) @@ -208,14 +215,11 @@ GEM flay (2.6.1) ruby_parser (~> 3.0) sexp_processor (~> 4.0) - flog (4.3.2) - ruby_parser (~> 3.1, > 3.1.0) - sexp_processor (~> 4.4) flowdock (0.7.1) httparty (~> 0.7) multi_json - fog-aws (0.9.2) - fog-core (~> 1.27) + fog-aws (0.11.0) + fog-core (~> 1.38) fog-json (~> 1.0) fog-xml (~> 0.1) ipaddress (~> 0.8) @@ -224,7 +228,7 @@ GEM fog-core (~> 1.27) fog-json (~> 1.0) fog-xml (~> 0.1) - fog-core (1.40.0) + fog-core (1.42.0) builder excon (~> 0.49) formatador (~> 0.2) @@ -278,18 +282,18 @@ GEM diff-lcs (~> 1.1) mime-types (>= 1.16, < 3) posix-spawn (~> 0.3) - gitlab_git (10.4.3) + gitlab-markup (1.5.0) + gitlab_git (10.6.8) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) rugged (~> 0.24.0) - gitlab_meta (7.0) gitlab_omniauth-ldap (1.2.1) net-ldap (~> 0.9) omniauth (~> 1.0) pyu-ruby-sasl (~> 0.0.3.1) rubyntlm (~> 0.3) - globalid (0.3.6) + globalid (0.3.7) activesupport (>= 4.1.0) gollum-grit_adapter (1.0.1) gitlab-grit (~> 2.7, >= 2.7.1) @@ -308,7 +312,7 @@ GEM json multi_json request_store (>= 1.0) - grape (0.13.0) + grape (0.15.0) activesupport builder hashie (>= 2.1.0) @@ -321,12 +325,19 @@ GEM grape-entity (0.4.8) activesupport multi_json (>= 1.3.2) - hamlit (2.5.0) + haml (4.0.7) + tilt + haml_lint (0.18.2) + haml (~> 4.0) + rake (>= 10, < 12) + rubocop (>= 0.36.0) + sysexits (~> 1.1) + hamlit (2.6.1) temple (~> 0.7.6) thor tilt - hashie (3.4.3) - health_check (2.1.0) + hashie (3.4.4) + health_check (2.2.1) rails (>= 4.0) hipchat (1.5.2) httparty @@ -335,11 +346,10 @@ GEM activesupport (>= 2) nokogiri (~> 1.4) htmlentities (4.3.4) - http_parser.rb (0.5.3) httparty (0.13.7) json (~> 1.8) multi_xml (>= 0.5.2) - httpclient (2.7.0.1) + httpclient (2.8.2) i18n (0.7.0) ice_nine (0.11.1) influxdb (0.2.3) @@ -357,6 +367,8 @@ GEM jquery-ui-rails (5.0.5) railties (>= 3.2.16) json (1.8.3) + json-schema (2.6.2) + addressable (~> 2.3.8) jwt (1.5.4) kaminari (0.17.0) actionpack (>= 3.0.0) @@ -390,9 +402,9 @@ GEM systemu (~> 2.6.2) mail (2.6.4) mime-types (>= 1.16, < 4) - mail_room (0.8.0) + mail_room (0.8.1) method_source (0.8.2) - mime-types (2.99.2) + mime-types (2.99.3) mimemagic (0.3.0) mini_portile2 (2.1.0) minitest (5.7.0) @@ -404,7 +416,7 @@ GEM nested_form (0.3.2) net-ldap (0.12.1) net-ssh (3.0.1) - newrelic_rpm (3.14.1.311) + newrelic_rpm (3.16.0.318) nokogiri (1.6.8) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) @@ -418,6 +430,7 @@ GEM rack (>= 1.2, < 3) octokit (4.3.0) sawyer (~> 0.7.0, >= 0.5.3) + oj (2.17.4) omniauth (1.3.1) hashie (>= 1.2, < 4) rack (>= 1.0, < 3) @@ -435,7 +448,7 @@ GEM addressable (~> 2.3) nokogiri (~> 1.6.6) omniauth (~> 1.2) - omniauth-facebook (3.0.0) + omniauth-facebook (4.0.0) omniauth-oauth2 (~> 1.2) omniauth-github (1.1.2) omniauth (~> 1.0) @@ -477,7 +490,7 @@ GEM orm_adapter (0.5.0) paranoia (2.1.4) activerecord (~> 4.0) - parser (2.3.1.2) + parser (2.3.1.4) ast (~> 2.2) pg (0.18.4) pkg-config (1.1.7) @@ -519,16 +532,16 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.7) - actionmailer (= 4.2.7) - actionpack (= 4.2.7) - actionview (= 4.2.7) - activejob (= 4.2.7) - activemodel (= 4.2.7) - activerecord (= 4.2.7) - activesupport (= 4.2.7) + rails (4.2.7.1) + actionmailer (= 4.2.7.1) + actionpack (= 4.2.7.1) + actionview (= 4.2.7.1) + activejob (= 4.2.7.1) + activemodel (= 4.2.7.1) + activerecord (= 4.2.7.1) + activesupport (= 4.2.7.1) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.7) + railties (= 4.2.7.1) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -538,13 +551,13 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) loofah (~> 2.0) - railties (4.2.7) - actionpack (= 4.2.7) - activesupport (= 4.2.7) + railties (4.2.7.1) + actionpack (= 4.2.7.1) + activesupport (= 4.2.7.1) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.1.0) - raindrops (0.15.0) + raindrops (0.17.0) rake (10.5.0) rb-fsevent (0.9.6) rb-inotify (0.9.5) @@ -578,11 +591,11 @@ GEM request_store (1.3.1) rerun (0.11.0) listen (~> 3.0) - responders (2.1.1) + responders (2.3.0) railties (>= 4.2.0, < 5.1) rinku (2.0.0) rotp (2.1.2) - rouge (2.0.5) + rouge (2.0.6) rqrcode (0.7.0) chunky_png rqrcode-rails3 (0.1.7) @@ -610,7 +623,7 @@ GEM rspec-retry (0.4.5) rspec-core rspec-support (3.5.0) - rubocop (0.41.2) + rubocop (0.43.0) parser (>= 2.3.1.1, < 3.0) powerpack (~> 0.1) rainbow (>= 1.99.1, < 3.0) @@ -620,7 +633,7 @@ GEM rubocop (>= 0.40.0) ruby-fogbugz (0.2.1) crack (~> 0.4) - ruby-prof (0.15.9) + ruby-prof (0.16.2) ruby-progressbar (1.8.1) ruby-saml (1.3.0) nokogiri (>= 1.5.10) @@ -635,7 +648,7 @@ GEM sanitize (2.1.0) nokogiri (>= 1.4.4) sass (3.4.22) - sass-rails (5.0.5) + sass-rails (5.0.6) railties (>= 4.0.0, < 6) sass (~> 3.1) sprockets (>= 2.8, < 4.0) @@ -655,34 +668,28 @@ GEM activesupport (>= 3.1) select2-rails (3.5.9.3) thor (~> 0.14) - sentry-raven (1.1.0) - faraday (>= 0.7.6) + sentry-raven (2.0.2) + faraday (>= 0.7.6, < 0.10.x) settingslogic (2.0.9) sexp_processor (4.7.0) sham_rack (1.3.6) rack shoulda-matchers (2.8.0) activesupport (>= 3.0.0) - sidekiq (4.1.4) + sidekiq (4.2.1) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) + rack-protection (~> 1.5) redis (~> 3.2, >= 3.2.1) - sinatra (>= 1.4.7) sidekiq-cron (0.4.0) redis-namespace (>= 1.5.2) rufus-scheduler (>= 2.0.24) sidekiq (>= 4.0.0) - simple_oauth (0.1.9) simplecov (0.12.0) docile (~> 1.1.0) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.0) - sinatra (1.4.7) - rack (~> 1.5) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) - six (0.2.0) slack-notifier (1.2.1) slop (3.6.0) spinach (0.8.10) @@ -702,10 +709,10 @@ GEM spring (>= 0.9.1) spring-commands-teaspoon (0.0.2) spring (>= 0.9.1) - sprockets (3.6.3) + sprockets (3.7.0) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-es6 (0.9.0) + sprockets-es6 (0.9.2) babel-source (>= 5.8.11) babel-transpiler sprockets (>= 3.0.0) @@ -723,9 +730,8 @@ GEM stringex (2.5.2) sys-filesystem (1.1.6) ffi + sysexits (1.2.0) systemu (2.6.5) - task_list (1.0.2) - html-pipeline teaspoon (1.1.5) railties (>= 3.2.5, < 6) teaspoon-jasmine (2.2.0) @@ -742,21 +748,11 @@ GEM tilt (2.0.5) timecop (0.8.1) timfel-krb5-auth (0.8.3) - tinder (1.10.1) - eventmachine (~> 1.0) - faraday (~> 0.9.0) - faraday_middleware (~> 0.9) - hashie (>= 1.0) - json (~> 1.8.0) - mime-types - multi_json (~> 1.7) - twitter-stream (~> 0.1) + truncato (0.7.8) + htmlentities (~> 4.3.1) + nokogiri (~> 1.6.1) turbolinks (2.5.3) coffee-rails - twitter-stream (0.1.16) - eventmachine (>= 0.12.8) - http_parser.rb (~> 0.5.1) - simple_oauth (~> 0.1.4) tzinfo (1.2.2) thread_safe (~> 0.1) u2f (0.2.1) @@ -767,10 +763,9 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.7.2) - unicode-display_width (1.1.0) - unicorn (4.9.0) + unicode-display_width (1.1.1) + unicorn (5.1.0) kgio (~> 2.6) - rack raindrops (~> 0.7) unicorn-worker-killer (0.4.4) get_process_mem (~> 0) @@ -778,13 +773,13 @@ GEM uniform_notifier (1.10.0) uuid (2.3.8) macaddr (~> 1.0) - version_sorter (2.0.0) + version_sorter (2.1.0) virtus (1.0.5) axiom-types (~> 0.1) coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) equalizer (~> 0.0, >= 0.0.9) - vmstat (2.1.1) + vmstat (2.2.0) warden (1.2.6) rack (>= 1.0) web-console (2.3.0) @@ -811,9 +806,10 @@ PLATFORMS DEPENDENCIES RedCloth (~> 4.3.2) - ace-rails-ap (~> 4.0.2) + ace-rails-ap (~> 4.1.0) activerecord-session_store (~> 1.0.0) - acts-as-taggable-on (~> 3.4) + activerecord_sane_schema_dumper (= 0.2) + acts-as-taggable-on (~> 4.0) addressable (~> 2.3.8) after_commit_queue (~> 1.3.0) akismet (~> 2.0) @@ -837,24 +833,25 @@ DEPENDENCIES capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) + chronic (~> 0.10.2) chronic_duration (~> 0.10.6) coffee-rails (~> 4.1.0) connection_pool (~> 2.0) creole (~> 0.5.0) d3_rails (~> 3.5.0) database_cleaner (~> 1.5.0) + deckar01-task_list (= 1.0.5) default_value_for (~> 3.0.0) - devise (~> 4.0) + devise (~> 4.2) devise-two-factor (~> 3.0.0) diffy (~> 3.0.3) - doorkeeper (~> 4.0) + doorkeeper (~> 4.2.0) dropzonejs-rails (~> 0.7.1) email_reply_parser (~> 0.5.8) email_spec (~> 1.6.0) factory_girl_rails (~> 4.6.0) ffaker (~> 2.0.0) flay (~> 2.6.1) - flog (~> 4.3.2) fog-aws (~> 0.9) fog-azure (~> 0.0) fog-core (~> 1.40) @@ -868,18 +865,18 @@ DEPENDENCIES gemnasium-gitlab-service (~> 0.2) gemojione (~> 3.0) github-linguist (~> 4.7.0) - github-markup (~> 1.4) gitlab-flowdock-git-hook (~> 1.0.1) - gitlab_git (~> 10.4.3) - gitlab_meta (= 7.0) + gitlab-markup (~> 1.5.0) + gitlab_git (~> 10.6.8) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.2) gollum-rugged_adapter (~> 0.4.2) gon (~> 6.1.0) - grape (~> 0.13.0) + grape (~> 0.15.0) grape-entity (~> 0.4.2) - hamlit (~> 2.5) - health_check (~> 2.1.0) + haml_lint (~> 0.18.2) + hamlit (~> 2.6.1) + health_check (~> 2.2.0) hipchat (~> 1.5.0) html-pipeline (~> 1.11.0) httparty (~> 0.13.3) @@ -888,6 +885,7 @@ DEPENDENCIES jquery-rails (~> 4.1.0) jquery-turbolinks (~> 2.1.0) jquery-ui-rails (~> 5.0.0) + json-schema (~> 2.6.2) jwt kaminari (~> 0.17.0) knapsack (~> 1.11.0) @@ -895,23 +893,24 @@ DEPENDENCIES license_finder (~> 2.1.0) licensee (~> 8.0.0) loofah (~> 2.0.3) - mail_room (~> 0.8) + mail_room (~> 0.8.1) method_source (~> 0.8) minitest (~> 5.7.0) mousetrap-rails (~> 1.4.6) mysql2 (~> 0.3.16) nested_form (~> 0.3.2) net-ssh (~> 3.0.1) - newrelic_rpm (~> 3.14) + newrelic_rpm (~> 3.16) nokogiri (~> 1.6.7, >= 1.6.7.2) oauth2 (~> 1.2.0) octokit (~> 4.3.0) + oj (~> 2.17.4) omniauth (~> 1.3.1) omniauth-auth0 (~> 1.4.1) omniauth-azure-oauth2 (~> 0.0.6) omniauth-bitbucket (~> 0.0.2) omniauth-cas3 (~> 1.1.2) - omniauth-facebook (~> 3.0.0) + omniauth-facebook (~> 4.0.0) omniauth-github (~> 1.1.1) omniauth-gitlab (~> 1.0.0) omniauth-google-oauth2 (~> 0.4.1) @@ -929,7 +928,7 @@ DEPENDENCIES rack-attack (~> 4.3.1) rack-cors (~> 0.4.0) rack-oauth2 (~> 1.2.1) - rails (= 4.2.7) + rails (= 4.2.7.1) rails-deprecated_sanitizer (~> 1.0.3) rainbow (~> 2.1.0) rblineprof (~> 0.3.6) @@ -939,32 +938,30 @@ DEPENDENCIES redis (~> 3.2) redis-namespace (~> 1.5.2) redis-rails (~> 4.0.0) - request_store (~> 1.3.0) + request_store (~> 1.3) rerun (~> 0.11.0) responders (~> 2.0) rouge (~> 2.0) rqrcode-rails3 (~> 0.1.7) rspec-rails (~> 3.5.0) rspec-retry (~> 0.4.5) - rubocop (~> 0.41.2) + rubocop (~> 0.43.0) rubocop-rspec (~> 1.5.0) ruby-fogbugz (~> 0.2.1) - ruby-prof (~> 0.15.9) + ruby-prof (~> 0.16.2) sanitize (~> 2.0) - sass-rails (~> 5.0.0) + sass-rails (~> 5.0.6) scss_lint (~> 0.47.0) sdoc (~> 0.3.20) seed-fu (~> 2.3.5) select2-rails (~> 3.5.9) - sentry-raven (~> 1.1.0) + sentry-raven (~> 2.0.0) settingslogic (~> 2.0.9) sham_rack (~> 1.3.6) shoulda-matchers (~> 2.8.0) - sidekiq (~> 4.0) + sidekiq (~> 4.2) sidekiq-cron (~> 0.4.0) simplecov (= 0.12.0) - sinatra (~> 1.4.4) - six (~> 0.2.0) slack-notifier (~> 1.2.0) spinach-rails (~> 0.2.1) spinach-rerun-reporter (~> 0.0.2) @@ -972,29 +969,29 @@ DEPENDENCIES spring-commands-rspec (~> 1.0.4) spring-commands-spinach (~> 1.1.0) spring-commands-teaspoon (~> 0.0.2) - sprockets (~> 3.6.0) - sprockets-es6 + sprockets (~> 3.7.0) + sprockets-es6 (~> 0.9.2) state_machines-activerecord (~> 0.4.0) sys-filesystem (~> 1.1.6) - task_list (~> 1.0.2) teaspoon (~> 1.1.0) teaspoon-jasmine (~> 2.2.0) test_after_commit (~> 0.4.2) thin (~> 1.7.0) - tinder (~> 1.10.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) unf (~> 0.1.4) - unicorn (~> 4.9.0) - unicorn-worker-killer (~> 0.4.2) - version_sorter (~> 2.0.0) + unicorn (~> 5.1.0) + unicorn-worker-killer (~> 0.4.4) + version_sorter (~> 2.1.0) virtus (~> 1.0.1) - vmstat (~> 2.1.1) + vmstat (~> 2.2) web-console (~> 2.0) webmock (~> 1.21.0) wikicloth (= 0.8.1) BUNDLED WITH - 1.12.5 + 1.13.2 diff --git a/PROCESS.md b/PROCESS.md index 8e1a3f7360f..8af660fbdd1 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -50,7 +50,7 @@ etc.). The most important thing is making sure valid issues receive feedback from the development team. Therefore the priority is mentioning developers that can help -on those issue. Please select someone with relevant experience from +on those issues. Please select someone with relevant experience from [GitLab core team][core-team]. If there is nobody mentioned with that expertise look in the commit history for the affected files to find someone. Avoid mentioning the lead developer, this is the person that is least likely to give a diff --git a/README.md b/README.md index fee93d5f9c3..a6b30aff5a0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # GitLab -[](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) +[](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) +[](http://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) [](https://codeclimate.com/github/gitlabhq/gitlabhq) +[](https://bestpractices.coreinfrastructure.org/projects/42) ## Canonical source -The source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/) and there are mirrors to make [contributing](CONTRIBUTING.md) as easy as possible. +The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). ## Open source software to collaborate on code @@ -69,7 +71,7 @@ Instructions on how to start GitLab and how to run the tests can be found in the GitLab is a Ruby on Rails application that runs on the following software: - Ubuntu/Debian/CentOS/RHEL -- Ruby (MRI) 2.1 +- Ruby (MRI) 2.3 - Git 2.7.4+ - Redis 2.8+ - MySQL or PostgreSQL @@ -1 +1 @@ -8.11.0-pre +8.13.0-pre diff --git a/app/assets/images/icon-link.png b/app/assets/images/icon-link.png Binary files differdeleted file mode 100644 index 5b55e12571c..00000000000 --- a/app/assets/images/icon-link.png +++ /dev/null diff --git a/app/assets/images/icon_anchor.svg b/app/assets/images/icon_anchor.svg new file mode 100644 index 00000000000..7e242586bad --- /dev/null +++ b/app/assets/images/icon_anchor.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#333" fill-rule="evenodd" d="M9.683 6.676l-.047-.048C8.27 5.26 6.07 5.243 4.726 6.588l-2.29 2.29c-1.344 1.344-1.328 3.544.04 4.91 1.366 1.368 3.564 1.385 4.908.04l1.753-1.752c-.695.074-1.457-.078-2.176-.444L5.934 12.66c-.634.634-1.67.625-2.312-.017-.642-.643-.65-1.677-.017-2.312L6.035 7.9c.634-.634 1.67-.625 2.312.017.024.024.048.05.07.075l.003-.002c.36.36.943.366 1.3.01.355-.356.35-.938-.01-1.3l-.027-.024zM6.58 9.586l.048.05c1.367 1.366 3.565 1.384 4.91.04l2.29-2.292c1.344-1.343 1.328-3.542-.04-4.91-1.366-1.366-3.564-1.384-4.908-.04L7.127 4.187c.695-.074 1.457.078 2.176.444l1.028-1.027c.635-.634 1.67-.624 2.313.017.643.644.652 1.678.018 2.312l-2.43 2.432c-.635.634-1.67.624-2.313-.018-.024-.024-.048-.05-.07-.075l-.003.004c-.36-.362-.943-.367-1.3-.01-.355.355-.35.937.01 1.3.01.007.018.015.027.023z"/></svg>
\ No newline at end of file diff --git a/app/assets/images/koding-logo.svg b/app/assets/images/koding-logo.svg new file mode 100644 index 00000000000..ad89d684d94 --- /dev/null +++ b/app/assets/images/koding-logo.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14"> + <g fill="#d6d7d9"> + <path d="M8.7 0L5.3.3l3.2 6.8-3.2 6.6 3.5.3L12 6.9z"/> + <ellipse cx="1.7" cy="11.1" rx="1.7" ry="1.7"/> + <ellipse cx="1.7" cy="5.6" rx="1.7" ry="1.7"/> + </g> +</svg>
\ No newline at end of file diff --git a/app/assets/javascripts/LabelManager.js b/app/assets/javascripts/LabelManager.js deleted file mode 100644 index 151455ce4a3..00000000000 --- a/app/assets/javascripts/LabelManager.js +++ /dev/null @@ -1,110 +0,0 @@ -(function() { - this.LabelManager = (function() { - LabelManager.prototype.errorMessage = 'Unable to update label prioritization at this time'; - - function LabelManager(opts) { - var ref, ref1, ref2; - if (opts == null) { - opts = {}; - } - this.togglePriorityButton = (ref = opts.togglePriorityButton) != null ? ref : $('.js-toggle-priority'), this.prioritizedLabels = (ref1 = opts.prioritizedLabels) != null ? ref1 : $('.js-prioritized-labels'), this.otherLabels = (ref2 = opts.otherLabels) != null ? ref2 : $('.js-other-labels'); - this.prioritizedLabels.sortable({ - items: 'li', - placeholder: 'list-placeholder', - axis: 'y', - update: this.onPrioritySortUpdate.bind(this) - }); - this.bindEvents(); - } - - LabelManager.prototype.bindEvents = function() { - return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); - }; - - LabelManager.prototype.onTogglePriorityClick = function(e) { - var $btn, $label, $tooltip, _this, action; - e.preventDefault(); - _this = e.data; - $btn = $(e.currentTarget); - $label = $("#" + ($btn.data('domId'))); - action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add'; - $tooltip = $("#" + ($btn.find('.has-tooltip:visible').attr('aria-describedby'))); - $tooltip.tooltip('destroy'); - return _this.toggleLabelPriority($label, action); - }; - - LabelManager.prototype.toggleLabelPriority = function($label, action, persistState) { - var $from, $target, _this, url, xhr; - if (persistState == null) { - persistState = true; - } - _this = this; - url = $label.find('.js-toggle-priority').data('url'); - $target = this.prioritizedLabels; - $from = this.otherLabels; - if (action === 'remove') { - $target = this.otherLabels; - $from = this.prioritizedLabels; - } - if ($from.find('li').length === 1) { - $from.find('.empty-message').removeClass('hidden'); - } - if (!$target.find('li').length) { - $target.find('.empty-message').addClass('hidden'); - } - $label.detach().appendTo($target); - if (!persistState) { - return; - } - if (action === 'remove') { - xhr = $.ajax({ - url: url, - type: 'DELETE' - }); - if (!$from.find('li').length) { - $from.find('.empty-message').removeClass('hidden'); - } - } else { - xhr = this.savePrioritySort($label, action); - } - return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action)); - }; - - LabelManager.prototype.onPrioritySortUpdate = function() { - var xhr; - xhr = this.savePrioritySort(); - return xhr.fail(function() { - return new Flash(this.errorMessage, 'alert'); - }); - }; - - LabelManager.prototype.savePrioritySort = function() { - return $.post({ - url: this.prioritizedLabels.data('url'), - data: { - label_ids: this.getSortedLabelsIds() - } - }); - }; - - LabelManager.prototype.rollbackLabelPosition = function($label, originalAction) { - var action; - action = originalAction === 'remove' ? 'add' : 'remove'; - this.toggleLabelPriority($label, action, false); - return new Flash(this.errorMessage, 'alert'); - }; - - LabelManager.prototype.getSortedLabelsIds = function() { - var sortedIds; - sortedIds = []; - this.prioritizedLabels.find('li').each(function() { - return sortedIds.push($(this).data('id')); - }); - return sortedIds; - }; - - return LabelManager; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/abuse_reports.js.es6 b/app/assets/javascripts/abuse_reports.js.es6 new file mode 100644 index 00000000000..2fe46b9fd06 --- /dev/null +++ b/app/assets/javascripts/abuse_reports.js.es6 @@ -0,0 +1,38 @@ +((global) => { + const MAX_MESSAGE_LENGTH = 500; + const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; + + class AbuseReports { + constructor() { + $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage); + $(document) + .off('click', MESSAGE_CELL_SELECTOR) + .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation); + } + + truncateLongMessage() { + const $messageCellElement = $(this); + const reportMessage = $messageCellElement.text(); + if (reportMessage.length > MAX_MESSAGE_LENGTH) { + $messageCellElement.data('original-message', reportMessage); + $messageCellElement.data('message-truncated', 'true'); + $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); + } + } + + toggleMessageTruncation() { + const $messageCellElement = $(this); + const originalMessage = $messageCellElement.data('original-message'); + if (!originalMessage) return; + if ($messageCellElement.data('message-truncated') === 'true') { + $messageCellElement.data('message-truncated', 'false'); + $messageCellElement.text(originalMessage); + } else { + $messageCellElement.data('message-truncated', 'true'); + $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`); + } + } + } + + global.AbuseReports = AbuseReports; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index 1ab3c2197d8..f4f8cf04184 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -12,7 +12,7 @@ } Activities.prototype.updateTooltips = function() { - return gl.utils.localTimeAgo($('.js-timeago', '#activity')); + return gl.utils.localTimeAgo($('.js-timeago', '.content_list')); }; Activities.prototype.reloadActivities = function() { @@ -21,16 +21,14 @@ }; Activities.prototype.toggleFilter = function(sender) { - var event_filters, filter; + var filter = sender.attr("id").split("_")[0]; + $('.event-filter .active').removeClass("active"); - event_filters = $.cookie("event_filter"); - filter = sender.attr("id").split("_")[0]; - $.cookie("event_filter", (event_filters !== filter ? filter : ""), { - path: '/' + $.cookie("event_filter", filter, { + path: gon.relative_url_root || '/' }); - if (event_filters !== filter) { - return sender.closest('li').toggleClass("active"); - } + + sender.closest('li').toggleClass("active"); }; return Activities; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 49c2ac0dac3..56ec1489f89 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,32 +5,30 @@ namespacesPath: "/api/:version/namespaces.json", groupProjectsPath: "/api/:version/groups/:id/projects.json", projectsPath: "/api/:version/projects.json?simple=true", - labelsPath: "/api/:version/projects/:id/labels", - licensePath: "/api/:version/licenses/:key", - gitignorePath: "/api/:version/gitignores/:key", - gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", + labelsPath: "/:namespace_path/:project_path/labels", + licensePath: "/api/:version/templates/licenses/:key", + gitignorePath: "/api/:version/templates/gitignores/:key", + gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", + issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", group: function(group_id, callback) { - var url; - url = Api.buildUrl(Api.groupPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupPath) + .replace(':id', group_id); return $.ajax({ url: url, - data: { - private_token: gon.api_token - }, dataType: "json" }).done(function(group) { return callback(group); }); }, - groups: function(query, skip_ldap, callback) { - var url; - url = Api.buildUrl(Api.groupsPath); + // Return groups list. Filtered by query + // Only active groups retrieved + groups: function(query, skip_ldap, skip_groups, callback) { + var url = Api.buildUrl(Api.groupsPath); return $.ajax({ url: url, data: { - private_token: gon.api_token, search: query, + skip_groups: skip_groups, per_page: 20 }, dataType: "json" @@ -38,13 +36,12 @@ return callback(groups); }); }, + // Return namespaces list. Filtered by query namespaces: function(query, callback) { - var url; - url = Api.buildUrl(Api.namespacesPath); + var url = Api.buildUrl(Api.namespacesPath); return $.ajax({ url: url, data: { - private_token: gon.api_token, search: query, per_page: 20 }, @@ -53,13 +50,12 @@ return callback(namespaces); }); }, + // Return projects list. Filtered by query projects: function(query, order, callback) { - var url; - url = Api.buildUrl(Api.projectsPath); + var url = Api.buildUrl(Api.projectsPath); return $.ajax({ url: url, data: { - private_token: gon.api_token, search: query, order_by: order, per_page: 20 @@ -69,15 +65,14 @@ return callback(projects); }); }, - newLabel: function(project_id, data, callback) { - var url; - url = Api.buildUrl(Api.labelsPath); - url = url.replace(':id', project_id); - data.private_token = gon.api_token; + newLabel: function(namespace_path, project_path, data, callback) { + var url = Api.buildUrl(Api.labelsPath) + .replace(':namespace_path', namespace_path) + .replace(':project_path', project_path); return $.ajax({ url: url, type: "POST", - data: data, + data: {'label': data}, dataType: "json" }).done(function(label) { return callback(label); @@ -85,14 +80,13 @@ return callback(message.responseJSON); }); }, + // Return group projects list. Filtered by query groupProjects: function(group_id, query, callback) { - var url; - url = Api.buildUrl(Api.groupProjectsPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { - private_token: gon.api_token, search: query, per_page: 20 }, @@ -101,9 +95,10 @@ return callback(projects); }); }, + // Return text for a specific license licenseText: function(key, data, callback) { - var url; - url = Api.buildUrl(Api.licensePath).replace(':key', key); + var url = Api.buildUrl(Api.licensePath) + .replace(':key', key); return $.ajax({ url: url, data: data @@ -112,19 +107,32 @@ }); }, gitignoreText: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + var url = Api.buildUrl(Api.gitignorePath) + .replace(':key', key); return $.get(url, function(gitignore) { return callback(gitignore); }); }, gitlabCiYml: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + var url = Api.buildUrl(Api.gitlabCiYmlPath) + .replace(':key', key); return $.get(url, function(file) { return callback(file); }); }, + issueTemplate: function(namespacePath, projectPath, key, type, callback) { + var url = Api.buildUrl(Api.issuableTemplatePath) + .replace(':key', key) + .replace(':type', type) + .replace(':project_path', projectPath) + .replace(':namespace_path', namespacePath); + $.ajax({ + url: url, + dataType: 'json' + }).done(function(file) { + callback(null, file); + }).error(callback); + }, buildUrl: function(url) { if (gon.relative_url_root != null) { url = gon.relative_url_root + url; diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 127e568adc9..8a61669822c 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,3 +1,9 @@ +// This is a manifest file that'll be compiled into including all the files listed below. +// Add new JavaScript/Coffee 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 jquery2 */ /*= require jquery-ui/autocomplete */ /*= require jquery-ui/datepicker */ @@ -26,8 +32,6 @@ /*= require bootstrap/tooltip */ /*= require bootstrap/popover */ /*= require select2 */ -/*= require ace/ace */ -/*= require ace/ext-searchbox */ /*= require underscore */ /*= require dropzone */ /*= require mousetrap */ @@ -41,6 +45,7 @@ /*= require date.format */ /*= require_directory ./behaviors */ /*= require_directory ./blob */ +/*= require_directory ./templates */ /*= require_directory ./commit */ /*= require_directory ./extensions */ /*= require_directory ./lib/utils */ @@ -77,6 +82,7 @@ } }; + // Disable button if text field is empty window.disableButtonIfEmptyField = function(field_selector, button_selector) { var closest_submit, field; field = $(field_selector); @@ -93,6 +99,7 @@ }); }; + // Disable button if any input field with given selector is empty window.disableButtonIfAnyEmptyField = function(form, form_selector, button_selector) { var closest_submit, updateButtons; closest_submit = form.find(button_selector); @@ -129,6 +136,8 @@ window.addEventListener("hashchange", shiftWindow); window.onload = function() { + // Scroll the window to avoid the topnav bar + // https://github.com/twitter/bootstrap/issues/1768 if (location.hash) { return setTimeout(shiftWindow, 100); } @@ -150,9 +159,13 @@ return $(this).select().one('mouseup', function(e) { return e.preventDefault(); }); + // Click a .js-select-on-focus field, select the contents + // Prevent a mouseup event from deselecting the input }); $('.remove-row').bind('ajax:success', function() { - return $(this).closest('li').fadeOut(); + $(this).tooltip('destroy') + .closest('li') + .fadeOut(); }); $('.js-remove-tr').bind('ajax:before', function() { return $(this).hide(); @@ -162,6 +175,7 @@ }); $('select.select2').select2({ width: 'resolve', + // Initialize select2 selects dropdownAutoWidth: true }); $('.js-select2').bind('select2-close', function() { @@ -169,25 +183,28 @@ $('.select2-container-active').removeClass('select2-container-active'); return $(':focus').blur(); }), 1); + // Close select2 on escape }); + // Initialize tooltips $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', placement: function(_, el) { - var $el; - $el = $(el); - return $el.data('placement') || 'bottom'; + return $(el).data('placement') || 'bottom'; } }); $('.trigger-submit').on('change', function() { return $(this).parents('form').submit(); + // Form submitter }); gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true); + // Flash if ((flash = $(".flash-container")).length > 0) { flash.click(function() { return $(this).fadeOut(); }); flash.show(); } + // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function(e) { var buttons; buttons = $('[type="submit"]', this); @@ -208,6 +225,7 @@ } }); $('.account-box').hover(function() { + // Show/Hide the profile menu when hovering the account box return $(this).toggleClass('hover'); }); $document.on('click', '.diff-content .js-show-suppressed-diff', function() { @@ -215,6 +233,7 @@ $container = $(this).parent(); $container.next('table').show(); return $container.remove(); + // Commit show suppressed diff }); $('.navbar-toggle').on('click', function() { $('.header-content .title').toggle(); @@ -222,9 +241,17 @@ $('.header-content .navbar-collapse').toggle(); return $('.navbar-toggle').toggleClass('active'); }); + // Show/hide comments on diff $body.on("click", ".js-toggle-diff-comments", function(e) { - $(this).toggleClass('active'); - $(this).closest(".diff-file").find(".notes_holder").toggle(); + var $this = $(this); + $this.toggleClass('active'); + var notesHolders = $this.closest('.diff-file').find('.notes_holder'); + if ($this.hasClass('active')) { + notesHolders.show().find('.hide').show(); + } else { + notesHolders.hide(); + } + $this.trigger('blur'); return e.preventDefault(); }); $document.off("click", '.js-confirm-danger'); @@ -279,42 +306,11 @@ gl.awardsHandler = new AwardsHandler(); checkInitialSidebarSize(); new Aside(); - if ($window.width() < 1024 && $.cookie('pin_nav') === 'true') { - $.cookie('pin_nav', 'false', { - path: '/', - expires: 365 * 10 - }); - $('.page-with-sidebar').toggleClass('page-sidebar-collapsed page-sidebar-expanded').removeClass('page-sidebar-pinned'); - $('.navbar-fixed-top').removeClass('header-pinned-nav'); - } - return $document.off('click', '.js-nav-pin').on('click', '.js-nav-pin', function(e) { - var $page, $pinBtn, $tooltip, $topNav, doPinNav, tooltipText; - e.preventDefault(); - $pinBtn = $(e.currentTarget); - $page = $('.page-with-sidebar'); - $topNav = $('.navbar-fixed-top'); - $tooltip = $("#" + ($pinBtn.attr('aria-describedby'))); - doPinNav = !$page.is('.page-sidebar-pinned'); - tooltipText = 'Pin navigation'; - $(this).toggleClass('is-active'); - if (doPinNav) { - $page.addClass('page-sidebar-pinned'); - $topNav.addClass('header-pinned-nav'); - } else { - $tooltip.remove(); - $page.removeClass('page-sidebar-pinned').toggleClass('page-sidebar-collapsed page-sidebar-expanded'); - $topNav.removeClass('header-pinned-nav').toggleClass('header-collapsed header-expanded'); - } - $.cookie('pin_nav', doPinNav, { - path: '/', - expires: 365 * 10 - }); - if ($.cookie('pin_nav') === 'true' || doPinNav) { - tooltipText = 'Unpin navigation'; - } - $tooltip.find('.tooltip-inner').text(tooltipText); - return $pinBtn.attr('title', tooltipText).tooltip('fixTitle'); - }); - }); + // bind sidebar events + new gl.Sidebar(); + + // Custom time ago + gl.utils.shortTimeAgo($('.js-short-timeago')); + }); }).call(this); diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 7116512d6b7..a9aec6e8ea4 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -16,7 +16,7 @@ } Autosave.prototype.restore = function() { - var e, error, text; + var e, text; if (window.localStorage == null) { return; } @@ -41,7 +41,7 @@ if ((text != null ? text.length : void 0) > 0) { try { return window.localStorage.setItem(this.key, text); - } catch (undefined) {} + } catch (error) {} } else { return this.reset(); } @@ -53,7 +53,7 @@ } try { return window.localStorage.removeItem(this.key); - } catch (undefined) {} + } catch (error) {} }; return Autosave; diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index ea683b31f75..44af1c135a0 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,5 +1,6 @@ (function() { this.AwardsHandler = (function() { + const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence function AwardsHandler() { this.aliases = gl.emojiAliases(); $(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) { @@ -85,6 +86,8 @@ AwardsHandler.prototype.positionMenu = function($menu, $addBtn) { var css, position; position = $addBtn.data('position'); + // The menu could potentially be off-screen or in a hidden overflow element + // So we position the element absolute in the body css = { top: ($addBtn.offset().top + $addBtn.outerHeight()) + "px" }; @@ -130,7 +133,7 @@ counter = $emojiButton.find('.js-counter'); counter.text(parseInt(counter.text()) + 1); $emojiButton.addClass('active'); - this.addMeToUserList(votesBlock, emoji); + this.addYouToUserList(votesBlock, emoji); return this.animateEmoji($emojiButton); } } else { @@ -161,23 +164,11 @@ $emojiButton = votesBlock.find("[data-emoji=" + mutualVote + "]").parent(); isAlreadyVoted = $emojiButton.hasClass('active'); if (isAlreadyVoted) { - this.showEmojiLoader($emojiButton); - return this.addAward(votesBlock, awardUrl, mutualVote, false, function() { - return $emojiButton.removeClass('is-loading'); - }); + this.addAward(votesBlock, awardUrl, mutualVote, false); } } }; - AwardsHandler.prototype.showEmojiLoader = function($emojiButton) { - var $loader; - $loader = $emojiButton.find('.fa-spinner'); - if (!$loader.length) { - $emojiButton.append('<i class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>'); - } - return $emojiButton.addClass('is-loading'); - }; - AwardsHandler.prototype.isActive = function($emojiButton) { return $emojiButton.hasClass('active'); }; @@ -188,11 +179,11 @@ counterNumber = parseInt(counter.text(), 10); if (counterNumber > 1) { counter.text(counterNumber - 1); - this.removeMeFromUserList($emojiButton, emoji); + this.removeYouFromUserList($emojiButton, emoji); } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') { $emojiButton.tooltip('destroy'); counter.text('0'); - this.removeMeFromUserList($emojiButton, emoji); + this.removeYouFromUserList($emojiButton, emoji); if ($emojiButton.parents('.note').length) { this.removeEmoji($emojiButton); } @@ -216,43 +207,48 @@ return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || ''; }; - AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) { + AwardsHandler.prototype.toSentence = function(list) { + if(list.length <= 2){ + return list.join(' and '); + } + else{ + return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1]; + } + }; + + AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) { var authors, awardBlock, newAuthors, originalTitle; awardBlock = $emojiButton; originalTitle = this.getAwardTooltip(awardBlock); - authors = originalTitle.split(', '); - authors.splice(authors.indexOf('me'), 1); - newAuthors = authors.join(', '); - awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors); - return this.resetTooltip(awardBlock); + authors = originalTitle.split(FROM_SENTENCE_REGEX); + authors.splice(authors.indexOf('You'), 1); + return awardBlock + .closest('.js-emoji-btn') + .removeData('title') + .removeAttr('data-title') + .removeAttr('data-original-title') + .attr('title', this.toSentence(authors)) + .tooltip('fixTitle'); }; - AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) { + AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) { var awardBlock, origTitle, users; awardBlock = this.findEmojiIcon(votesBlock, emoji).parent(); origTitle = this.getAwardTooltip(awardBlock); users = []; if (origTitle) { - users = origTitle.trim().split(', '); + users = origTitle.trim().split(FROM_SENTENCE_REGEX); } - users.push('me'); - awardBlock.attr('title', users.join(', ')); - return this.resetTooltip(awardBlock); - }; - - AwardsHandler.prototype.resetTooltip = function(award) { - var cb; - award.tooltip('destroy'); - cb = function() { - return award.tooltip(); - }; - return setTimeout(cb, 200); + users.unshift('You'); + return awardBlock + .attr('title', this.toSentence(users)) + .tooltip('fixTitle'); }; AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) { var $emojiButton, buttonHtml, emojiCssClass; emojiCssClass = this.resolveNameToCssClass(emoji); - buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>"; + buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='You' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>"; $emojiButton = $(buttonHtml); $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji); this.animateEmoji($emojiButton); @@ -261,12 +257,12 @@ }; AwardsHandler.prototype.animateEmoji = function($emoji) { - var className; - className = 'pulse animated'; + var className = 'pulse animated once short'; $emoji.addClass(className); - return setTimeout((function() { - return $emoji.removeClass(className); - }), 321); + + $emoji.on('webkitAnimationEnd animationEnd', function() { + $(this).removeClass(className); + }); }; AwardsHandler.prototype.createEmoji = function(votesBlock, emoji) { @@ -290,6 +286,7 @@ if (emojiIcon.length > 0) { unicodeName = emojiIcon.data('unicode-name'); } else { + // Find by alias unicodeName = $(".emoji-menu-content [data-aliases*=':" + emoji + ":']").data('unicode-name'); } return "emoji-" + unicodeName; @@ -326,6 +323,7 @@ frequentlyUsedEmojis = this.getFrequentlyUsedEmojis(); frequentlyUsedEmojis.push(emoji); return $.cookie('frequently_used_emojis', frequentlyUsedEmojis.join(','), { + path: gon.relative_url_root || '/', expires: 365 }); }; @@ -355,9 +353,11 @@ return function(ev) { var found_emojis, h5, term, ul; term = $(ev.target).val(); + // Clean previous search results $('ul.emoji-menu-search, h5.emoji-search').remove(); if (term) { - h5 = $('<h5>').text('Search results'); + // Generate a search result block + h5 = $('<h5 class="emoji-search" />').text('Search results'); found_emojis = _this.searchEmojis(term).show(); ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(found_emojis); $('.emoji-menu-content ul, .emoji-menu-content h5').hide(); diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index f977a1e8a7b..dc8ae601961 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,7 +1,5 @@ /*= require jquery.ba-resize */ - - /*= require autosize */ (function() { diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js index 3631d1b74ac..1df681a4816 100644 --- a/app/assets/javascripts/behaviors/details_behavior.js +++ b/app/assets/javascripts/behaviors/details_behavior.js @@ -5,6 +5,12 @@ container = $(this).closest(".js-details-container"); return container.toggleClass("open"); }); + // Show details content. Hides link after click. + // + // %div + // %a.js-details-expand + // %div.js-details-content + // return $("body").on("click", ".js-details-expand", function(e) { $(this).next('.js-details-content').removeClass("hide"); $(this).hide(); diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 3527d0a95fc..54b7360ab41 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -1,6 +1,20 @@ - +// Quick Submit behavior +// +// When a child field of a form with a `js-quick-submit` class receives a +// "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form +// is submitted. +// /*= require extensions/jquery */ +// +// ### Example Markup +// +// <form action="/foo" class="js-quick-submit"> +// <input type="text" /> +// <textarea></textarea> +// <input type="submit" value="Submit" /> +// </form> +// (function() { var isMac, keyCodeIs; @@ -17,6 +31,7 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', function(e) { var $form, $submit_button; + // Enter if (!keyCodeIs(e, 13)) { return; } @@ -33,8 +48,11 @@ return $form.submit(); }); + // If the user tabs to a submit button on a `js-quick-submit` form, display a + // tooltip to let them know they could've used the hotkey $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function(e) { var $this, title; + // Tab if (!keyCodeIs(e, 9)) { return; } diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index db0b36b24e9..894034bdd54 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -1,6 +1,18 @@ - +// Requires Input behavior +// +// When called on a form with input fields with the `required` attribute, the +// form's submit button will be disabled until all required fields have values. +// /*= require extensions/jquery */ +// +// ### Example Markup +// +// <form class="js-requires-input"> +// <input type="text" required="required"> +// <input type="submit" value="Submit"> +// </form> +// (function() { $.fn.requiresInput = function() { var $button, $form, fieldSelector, requireInput, required; @@ -11,14 +23,17 @@ requireInput = function() { var values; values = _.map($(fieldSelector, $form), function(field) { + // Collect the input values of *all* required fields return field.value; }); + // Disable the button if any required fields are empty if (values.length && _.any(values, _.isEmpty)) { return $button.disable(); } else { return $button.enable(); } }; + // Set initial button state requireInput(); return $form.on('change input', fieldSelector, requireInput); }; @@ -27,6 +42,8 @@ var $form, hideOrShowHelpBlock; $form = $('form.js-requires-input'); $form.requiresInput(); + // Hide or Show the help block when creating a new project + // based on the option selected hideOrShowHelpBlock = function(form) { var selected; selected = $('.js-select-namespace option:selected'); diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 1b7b63489ea..a6ce378d67a 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -1,10 +1,33 @@ -(function() { +(function(w) { $(function() { - return $("body").on("click", ".js-toggle-button", function(e) { - $(this).find('i').toggleClass('fa fa-chevron-down').toggleClass('fa fa-chevron-up'); - $(this).closest(".js-toggle-container").find(".js-toggle-content").toggle(); - return e.preventDefault(); + // Toggle button. Show/hide content inside parent container. + // Button does not change visibility. If button has icon - it changes chevron style. + // + // %div.js-toggle-container + // %a.js-toggle-button + // %div.js-toggle-content + // + $('body').on('click', '.js-toggle-button', function(e) { + e.preventDefault(); + $(this) + .find('.fa') + .toggleClass('fa-chevron-down fa-chevron-up') + .end() + .closest('.js-toggle-container') + .find('.js-toggle-content') + .toggle() + ; }); - }); -}).call(this); + // If we're accessing a permalink, ensure it is not inside a + // closed js-toggle-container! + var hash = w.gl.utils.getLocationHash(); + var anchor = hash && document.getElementById(hash); + var container = anchor && $(anchor).closest('.js-toggle-container'); + + if (container && container.find('.js-toggle-content').is(':hidden')) { + container.find('.js-toggle-button').trigger('click'); + anchor.scrollIntoView(); + } + }); +})(window); diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js b/app/assets/javascripts/blob/blob_ci_yaml.js deleted file mode 100644 index 68758574967..00000000000 --- a/app/assets/javascripts/blob/blob_ci_yaml.js +++ /dev/null @@ -1,46 +0,0 @@ - -/*= require blob/template_selector */ - -(function() { - var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, - hasProp = {}.hasOwnProperty; - - this.BlobCiYamlSelector = (function(superClass) { - extend(BlobCiYamlSelector, superClass); - - function BlobCiYamlSelector() { - return BlobCiYamlSelector.__super__.constructor.apply(this, arguments); - } - - BlobCiYamlSelector.prototype.requestFile = function(query) { - return Api.gitlabCiYml(query.name, this.requestFileSuccess.bind(this)); - }; - - return BlobCiYamlSelector; - - })(TemplateSelector); - - this.BlobCiYamlSelectors = (function() { - function BlobCiYamlSelectors(opts) { - var ref; - this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-gitlab-ci-yml-selector'), this.editor = opts.editor; - this.$dropdowns.each((function(_this) { - return function(i, dropdown) { - var $dropdown; - $dropdown = $(dropdown); - return new BlobCiYamlSelector({ - pattern: /(.gitlab-ci.yml)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), - dropdown: $dropdown, - editor: _this.editor - }); - }; - })(this)); - } - - return BlobCiYamlSelectors; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 new file mode 100644 index 00000000000..d6ea4f84f57 --- /dev/null +++ b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 @@ -0,0 +1,40 @@ +/*= require blob/template_selector */ +((global) => { + + class BlobCiYamlSelector extends gl.TemplateSelector { + requestFile(query) { + return Api.gitlabCiYml(query.name, this.requestFileSuccess.bind(this)); + } + + requestFileSuccess(file) { + return super.requestFileSuccess(file); + } + } + + global.BlobCiYamlSelector = BlobCiYamlSelector; + + class BlobCiYamlSelectors { + constructor({ editor, $dropdowns } = {}) { + this.editor = editor; + this.$dropdowns = $dropdowns || $('.js-gitlab-ci-yml-selector'); + this.initSelectors(); + } + + initSelectors() { + const editor = this.editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new BlobCiYamlSelector({ + editor, + pattern: /(.gitlab-ci.yml)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), + dropdown: $dropdown + }); + }); + } + } + + global.BlobCiYamlSelectors = BlobCiYamlSelectors; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index f4044f22db2..8cca1aa9232 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -8,6 +8,8 @@ autoDiscover: false, autoProcessQueue: false, url: form.attr('action'), + // Rails uses a hidden input field for PUT + // http://stackoverflow.com/questions/21056482/how-to-set-method-put-in-form-tag-in-rails method: method, clickable: true, uploadMultiple: false, @@ -36,6 +38,7 @@ formData.append('commit_message', form.find('.js-commit-message').val()); }); }, + // Override behavior of adding error underneath preview error: function(file, errorMessage) { var stripped; stripped = $("<div/>").html(errorMessage).text(); diff --git a/app/assets/javascripts/blob/blob_gitignore_selector.js b/app/assets/javascripts/blob/blob_gitignore_selector.js index 54a09e919f8..cd746b05cf6 100644 --- a/app/assets/javascripts/blob/blob_gitignore_selector.js +++ b/app/assets/javascripts/blob/blob_gitignore_selector.js @@ -18,6 +18,6 @@ return BlobGitignoreSelector; - })(TemplateSelector); + })(gl.TemplateSelector); }).call(this); diff --git a/app/assets/javascripts/blob/blob_license_selector.js b/app/assets/javascripts/blob/blob_license_selector.js index 9a8ef08f4e5..2701df3e6de 100644 --- a/app/assets/javascripts/blob/blob_license_selector.js +++ b/app/assets/javascripts/blob/blob_license_selector.js @@ -23,6 +23,6 @@ return BlobLicenseSelector; - })(TemplateSelector); + })(gl.TemplateSelector); }).call(this); diff --git a/app/assets/javascripts/blob/blob_license_selectors.js b/app/assets/javascripts/blob/blob_license_selectors.js deleted file mode 100644 index 39237705e8d..00000000000 --- a/app/assets/javascripts/blob/blob_license_selectors.js +++ /dev/null @@ -1,25 +0,0 @@ -(function() { - this.BlobLicenseSelectors = (function() { - function BlobLicenseSelectors(opts) { - var ref; - this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-license-selector'), this.editor = opts.editor; - this.$dropdowns.each((function(_this) { - return function(i, dropdown) { - var $dropdown; - $dropdown = $(dropdown); - return new BlobLicenseSelector({ - pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-license-selector-wrap'), - dropdown: $dropdown, - editor: _this.editor - }); - }; - })(this)); - } - - return BlobLicenseSelectors; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/blob/blob_license_selectors.js.es6 b/app/assets/javascripts/blob/blob_license_selectors.js.es6 new file mode 100644 index 00000000000..153ed457559 --- /dev/null +++ b/app/assets/javascripts/blob/blob_license_selectors.js.es6 @@ -0,0 +1,21 @@ +((global) => { + class BlobLicenseSelectors { + constructor({ $dropdowns, editor }) { + this.$dropdowns = $('.js-license-selector'); + this.editor = editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new BlobLicenseSelector({ + editor, + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-license-selector-wrap'), + dropdown: $dropdown, + }); + }); + } + } + + global.BlobLicenseSelectors = BlobLicenseSelectors; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js deleted file mode 100644 index 2cf0a6631b8..00000000000 --- a/app/assets/javascripts/blob/template_selector.js +++ /dev/null @@ -1,74 +0,0 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - - this.TemplateSelector = (function() { - function TemplateSelector(opts) { - var ref; - if (opts == null) { - opts = {}; - } - this.onClick = bind(this.onClick, this); - this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); - this.buildDropdown(); - this.bindEvents(); - this.onFilenameUpdate(); - } - - TemplateSelector.prototype.buildDropdown = function() { - return this.dropdown.glDropdown({ - data: this.data, - filterable: true, - selectable: true, - toggleLabel: this.toggleLabel, - search: { - fields: ['name'] - }, - clicked: this.onClick, - text: function(item) { - return item.name; - } - }); - }; - - TemplateSelector.prototype.bindEvents = function() { - return this.$input.on('keyup blur', (function(_this) { - return function(e) { - return _this.onFilenameUpdate(); - }; - })(this)); - }; - - TemplateSelector.prototype.toggleLabel = function(item) { - return item.name; - }; - - TemplateSelector.prototype.onFilenameUpdate = function() { - var filenameMatches; - if (!this.$input.length) { - return; - } - filenameMatches = this.pattern.test(this.$input.val().trim()); - if (!filenameMatches) { - this.wrapper.addClass('hidden'); - return; - } - return this.wrapper.removeClass('hidden'); - }; - - TemplateSelector.prototype.onClick = function(item, el, e) { - e.preventDefault(); - return this.requestFile(item); - }; - - TemplateSelector.prototype.requestFile = function(item) {}; - - TemplateSelector.prototype.requestFileSuccess = function(file) { - this.editor.setValue(file.content, 1); - return this.editor.focus(); - }; - - return TemplateSelector; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/blob/template_selector.js.es6 b/app/assets/javascripts/blob/template_selector.js.es6 new file mode 100644 index 00000000000..4e309e480b0 --- /dev/null +++ b/app/assets/javascripts/blob/template_selector.js.es6 @@ -0,0 +1,102 @@ +((global) => { + class TemplateSelector { + constructor({ dropdown, data, pattern, wrapper, editor, fileEndpoint, $input } = {}) { + this.onClick = this.onClick.bind(this); + this.dropdown = dropdown; + this.data = data; + this.pattern = pattern; + this.wrapper = wrapper; + this.editor = editor; + this.fileEndpoint = fileEndpoint; + this.$input = $input || $('#file_name'); + this.dropdownIcon = $('.fa-chevron-down', this.dropdown); + this.buildDropdown(); + this.bindEvents(); + this.onFilenameUpdate(); + + this.autosizeUpdateEvent = document.createEvent('Event'); + this.autosizeUpdateEvent.initEvent('autosize:update', true, false); + } + + buildDropdown() { + return this.dropdown.glDropdown({ + data: this.data, + filterable: true, + selectable: true, + toggleLabel: this.toggleLabel, + search: { + fields: ['name'] + }, + clicked: this.onClick, + text: function(item) { + return item.name; + } + }); + } + + bindEvents() { + return this.$input.on('keyup blur', (e) => this.onFilenameUpdate()); + } + + toggleLabel(item) { + return item.name; + } + + onFilenameUpdate() { + var filenameMatches; + if (!this.$input.length) { + return; + } + filenameMatches = this.pattern.test(this.$input.val().trim()); + if (!filenameMatches) { + this.wrapper.addClass('hidden'); + return; + } + return this.wrapper.removeClass('hidden'); + } + + onClick(item, el, e) { + e.preventDefault(); + return this.requestFile(item); + } + + requestFile(item) { + // This `requestFile` method is an abstract method that should + // be added by all subclasses. + } + + // To be implemented on the extending class + // e.g. + // Api.gitignoreText item.name, @requestFileSuccess.bind(@) + requestFileSuccess(file, { skipFocus, append } = {}) { + const oldValue = this.editor.getValue(); + let newValue = file.content; + + if (append && oldValue.length && oldValue !== newValue) { + newValue = oldValue + '\n\n' + newValue; + } + + this.editor.setValue(newValue, 1); + if (!skipFocus) this.editor.focus(); + + if (this.editor instanceof jQuery) { + this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent); + } + } + + startLoadingSpinner() { + this.dropdownIcon + .addClass('fa-spinner fa-spin') + .removeClass('fa-chevron-down'); + } + + stopLoadingSpinner() { + this.dropdownIcon + .addClass('fa-chevron-down') + .removeClass('fa-spinner fa-spin'); + } + } + + global.TemplateSelector = TemplateSelector; + })(window.gl || ( window.gl = {})); + diff --git a/app/assets/javascripts/blob_edit/blob_edit_bundle.js b/app/assets/javascripts/blob_edit/blob_edit_bundle.js new file mode 100644 index 00000000000..2afef43f3d6 --- /dev/null +++ b/app/assets/javascripts/blob_edit/blob_edit_bundle.js @@ -0,0 +1,12 @@ +/*= require_tree . */ + +(function() { + $(function() { + var url = $(".js-edit-blob-form").data("relative-url-root"); + url += $(".js-edit-blob-form").data("assets-prefix"); + + var blob = new EditBlob(url, $('.js-edit-blob-form').data('blob-language')); + new NewCommitForm($('.js-edit-blob-form')); + }); + +}).call(this); diff --git a/app/assets/javascripts/blob/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 649c79daee8..8db4f6a3b28 100644 --- a/app/assets/javascripts/blob/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -18,15 +18,18 @@ return function() { return $("#file-content").val(_this.editor.getValue()); }; + // Before a form submission, move the content from the Ace editor into the + // submitted textarea })(this)); this.initModePanesAndLinks(); - new BlobLicenseSelectors({ + this.initSoftWrap(); + new gl.BlobLicenseSelectors({ editor: this.editor }); new BlobGitignoreSelectors({ editor: this.editor }); - new BlobCiYamlSelectors({ + new gl.BlobCiYamlSelectors({ editor: this.editor }); } @@ -48,6 +51,7 @@ this.$editModePanes.hide(); currentPane.fadeIn(200); if (paneId === "#preview") { + this.$toggleButton.hide(); return $.post(currentLink.data("preview-url"), { content: this.editor.getValue() }, function(response) { @@ -55,10 +59,23 @@ return currentPane.syntaxHighlight(); }); } else { + this.$toggleButton.show(); return this.editor.focus(); } }; + EditBlob.prototype.initSoftWrap = function() { + this.isSoftWrapped = false; + this.$toggleButton = $('.soft-wrap-toggle'); + this.$toggleButton.on('click', this.toggleSoftWrap.bind(this)); + }; + + EditBlob.prototype.toggleSoftWrap = function(e) { + this.isSoftWrapped = !this.isSoftWrapped; + this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); + this.editor.getSession().setUseWrapMode(this.isSoftWrapped); + }; + return EditBlob; })(); diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 new file mode 100644 index 00000000000..d4f8f4b9420 --- /dev/null +++ b/app/assets/javascripts/boards/boards_bundle.js.es6 @@ -0,0 +1,65 @@ +//= require vue +//= require vue-resource +//= require Sortable +//= require_tree ./models +//= require_tree ./stores +//= require_tree ./services +//= require_tree ./mixins +//= require ./components/board +//= require ./components/new_list_dropdown +//= require ./vue_resource_interceptor + +$(() => { + const $boardApp = document.getElementById('board-app'), + Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + + if (gl.IssueBoardsApp) { + gl.IssueBoardsApp.$destroy(true); + } + + gl.IssueBoardsApp = new Vue({ + el: $boardApp, + components: { + 'board': gl.issueBoards.Board + }, + data: { + state: Store.state, + loading: true, + endpoint: $boardApp.dataset.endpoint, + boardId: $boardApp.dataset.boardId, + disabled: $boardApp.dataset.disabled === 'true', + issueLinkBase: $boardApp.dataset.issueLinkBase + }, + init: Store.create.bind(Store), + created () { + gl.boardService = new BoardService(this.endpoint, this.boardId); + }, + ready () { + Store.disabled = this.disabled; + gl.boardService.all() + .then((resp) => { + resp.json().forEach((board) => { + const list = Store.addList(board); + + if (list.type === 'done') { + list.position = Infinity; + } else if (list.type === 'backlog') { + list.position = -1; + } + }); + + Store.addBlankState(); + this.loading = false; + }); + } + }); + + gl.IssueBoardsSearch = new Vue({ + el: '#js-boards-seach', + data: { + filters: Store.state.filters + } + }); +}); diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6 new file mode 100644 index 00000000000..cacb36a897f --- /dev/null +++ b/app/assets/javascripts/boards/components/board.js.es6 @@ -0,0 +1,72 @@ +//= require ./board_blank_state +//= require ./board_delete +//= require ./board_list + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.Board = Vue.extend({ + components: { + 'board-list': gl.issueBoards.BoardList, + 'board-delete': gl.issueBoards.BoardDelete, + 'board-blank-state': gl.issueBoards.BoardBlankState + }, + props: { + list: Object, + disabled: Boolean, + issueLinkBase: String + }, + data () { + return { + filters: Store.state.filters, + showIssueForm: false + }; + }, + watch: { + filters: { + handler () { + this.list.page = 1; + this.list.getIssues(true); + }, + deep: true + } + }, + methods: { + showNewIssueForm() { + this.showIssueForm = !this.showIssueForm; + } + }, + ready () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + disabled: this.disabled, + group: 'boards', + draggable: '.is-draggable', + handle: '.js-board-handle', + onEnd: (e) => { + gl.issueBoards.onEnd(); + + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { + const order = this.sortable.toArray(), + $board = this.$parent.$refs.board[e.oldIndex + 1], + list = $board.list; + + $board.$destroy(true); + + this.$nextTick(() => { + Store.state.lists.splice(e.newIndex, 0, list); + Store.moveList(list, order); + }); + } + } + }); + + this.sortable = Sortable.create(this.$el.parentNode, options); + }, + beforeDestroy () { + Store.state.lists.$remove(this.list); + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6 new file mode 100644 index 00000000000..ff90f2d6d75 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_blank_state.js.es6 @@ -0,0 +1,47 @@ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardBlankState = Vue.extend({ + data () { + return { + predefinedLabels: [ + new ListLabel({ title: 'To Do', color: '#F0AD4E' }), + new ListLabel({ title: 'Doing', color: '#5CB85C' }) + ] + } + }, + methods: { + addDefaultLists () { + this.clearBlankState(); + + this.predefinedLabels.forEach((label, i) => { + Store.addList({ + title: label.title, + position: i, + list_type: 'label', + label: { + title: label.title, + color: label.color + } + }); + }); + + // Save the labels + gl.boardService.generateDefaultLists() + .then((resp) => { + resp.json().forEach((listObj) => { + const list = Store.findList('title', listObj.title); + + list.id = listObj.id; + list.label.id = listObj.label.id; + list.getIssues(); + }); + }); + }, + clearBlankState: Store.removeBlankState.bind(Store) + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6 new file mode 100644 index 00000000000..4a7cfeaeab2 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card.js.es6 @@ -0,0 +1,43 @@ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardCard = Vue.extend({ + props: { + list: Object, + issue: Object, + issueLinkBase: String, + disabled: Boolean, + index: Number + }, + methods: { + filterByLabel (label, e) { + let labelToggleText = label.title; + const labelIndex = Store.state.filters['label_name'].indexOf(label.title); + $(e.target).tooltip('hide'); + + if (labelIndex === -1) { + Store.state.filters['label_name'].push(label.title); + $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`); + } else { + Store.state.filters['label_name'].splice(labelIndex, 1); + labelToggleText = Store.state.filters['label_name'][0]; + $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove(); + } + + const selectedLabels = Store.state.filters['label_name']; + if (selectedLabels.length === 0) { + labelToggleText = 'Label'; + } else if (selectedLabels.length > 1) { + labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`; + } + + $('.labels-filter .dropdown-toggle-text').text(labelToggleText); + + Store.updateFiltersUrl(); + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6 new file mode 100644 index 00000000000..34653cd48ef --- /dev/null +++ b/app/assets/javascripts/boards/components/board_delete.js.es6 @@ -0,0 +1,19 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardDelete = Vue.extend({ + props: { + list: Object + }, + methods: { + deleteBoard () { + $(this.$el).tooltip('hide'); + + if (confirm('Are you sure you want to delete this list?')) { + this.list.destroy(); + } + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6 new file mode 100644 index 00000000000..7022a29e818 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list.js.es6 @@ -0,0 +1,106 @@ +//= require ./board_card +//= require ./board_new_issue + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardList = Vue.extend({ + components: { + 'board-card': gl.issueBoards.BoardCard, + 'board-new-issue': gl.issueBoards.BoardNewIssue + }, + props: { + disabled: Boolean, + list: Object, + issues: Array, + loading: Boolean, + issueLinkBase: String, + showIssueForm: Boolean + }, + data () { + return { + scrollOffset: 250, + filters: Store.state.filters, + showCount: false + }; + }, + watch: { + filters: { + handler () { + this.list.loadingMore = false; + this.$els.list.scrollTop = 0; + }, + deep: true + }, + issues () { + this.$nextTick(() => { + if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { + this.list.page++; + this.list.getIssues(false); + } + + if (this.scrollHeight() > this.listHeight()) { + this.showCount = true; + } else { + this.showCount = false; + } + }); + } + }, + methods: { + listHeight () { + return this.$els.list.getBoundingClientRect().height; + }, + scrollHeight () { + return this.$els.list.scrollHeight; + }, + scrollTop () { + return this.$els.list.scrollTop + this.listHeight(); + }, + loadNextPage () { + const getIssues = this.list.nextPage(); + + if (getIssues) { + this.list.loadingMore = true; + getIssues.then(() => { + this.list.loadingMore = false; + }); + } + }, + }, + ready () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + group: 'issues', + sort: false, + disabled: this.disabled, + filter: '.board-list-count, .is-disabled', + onStart: (e) => { + const card = this.$refs.issue[e.oldIndex]; + + Store.moving.issue = card.issue; + Store.moving.list = card.list; + + gl.issueBoards.onStart(); + }, + onAdd: (e) => { + gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue); + }, + onRemove: (e) => { + this.$refs.issue[e.oldIndex].$destroy(true); + } + }); + + this.sortable = Sortable.create(this.$els.list, options); + + // Scroll event on list to load more + this.$els.list.onscroll = () => { + if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { + this.loadNextPage(); + } + }; + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_new_issue.js.es6 b/app/assets/javascripts/boards/components/board_new_issue.js.es6 new file mode 100644 index 00000000000..a4fad422eca --- /dev/null +++ b/app/assets/javascripts/boards/components/board_new_issue.js.es6 @@ -0,0 +1,58 @@ +(() => { + window.gl = window.gl || {}; + + gl.issueBoards.BoardNewIssue = Vue.extend({ + props: { + list: Object, + showIssueForm: Boolean + }, + data() { + return { + title: '', + error: false + }; + }, + watch: { + showIssueForm () { + this.$els.input.focus(); + } + }, + methods: { + submit(e) { + e.preventDefault(); + if (this.title.trim() === '') return; + + this.error = false; + + const labels = this.list.label ? [this.list.label] : []; + const issue = new ListIssue({ + title: this.title, + labels + }); + + this.list.newIssue(issue) + .then((data) => { + // Need this because our jQuery very kindly disables buttons on ALL form submissions + $(this.$els.submitButton).enable(); + }) + .catch(() => { + // Need this because our jQuery very kindly disables buttons on ALL form submissions + $(this.$els.submitButton).enable(); + + // Remove the issue + this.list.removeIssue(issue); + + // Show error message + this.error = true; + this.showIssueForm = true; + }); + + this.cancel(); + }, + cancel() { + this.showIssueForm = false; + this.title = ''; + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 new file mode 100644 index 00000000000..6ccd83e2d84 --- /dev/null +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 @@ -0,0 +1,53 @@ +$(() => { + const Store = gl.issueBoards.BoardsStore; + + $('.js-new-board-list').each(function () { + const $this = $(this); + new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); + + $this.glDropdown({ + data(term, callback) { + $.get($this.attr('data-labels')) + .then((resp) => { + callback(resp); + }); + }, + renderRow (label) { + const active = Store.findList('title', label.title), + $li = $('<li />'), + $a = $('<a />', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }), + $labelColor = $('<span />', { + class: 'dropdown-label-box', + style: `background-color: ${label.color}` + }); + + return $li.append($a.prepend($labelColor)); + }, + search: { + fields: ['title'] + }, + filterable: true, + selectable: true, + clicked (label, $el, e) { + e.preventDefault(); + + if (!Store.findList('title', label.title)) { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color + } + }); + } + } + }); + }); +}); diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 new file mode 100644 index 00000000000..f629d45c587 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 @@ -0,0 +1,35 @@ +((w) => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.onStart = () => { + $('.has-tooltip').tooltip('hide') + .tooltip('disable'); + document.body.classList.add('is-dragging'); + }; + + gl.issueBoards.onEnd = () => { + $('.has-tooltip').tooltip('enable'); + document.body.classList.remove('is-dragging'); + }; + + gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; + + gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { + let defaultSortOptions = { + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + filter: '.has-tooltip, .btn', + delay: gl.issueBoards.touchEnabled ? 100 : 0, + scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, + scrollSpeed: 20, + onStart: gl.issueBoards.onStart, + onEnd: gl.issueBoards.onEnd + } + + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); + return defaultSortOptions; + }; +})(window); diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6 new file mode 100644 index 00000000000..eb082103de9 --- /dev/null +++ b/app/assets/javascripts/boards/models/issue.js.es6 @@ -0,0 +1,44 @@ +class ListIssue { + constructor (obj) { + this.id = obj.iid; + this.title = obj.title; + this.confidential = obj.confidential; + this.labels = []; + + if (obj.assignee) { + this.assignee = new ListUser(obj.assignee); + } + + obj.labels.forEach((label) => { + this.labels.push(new ListLabel(label)); + }); + + this.priority = this.labels.reduce((max, label) => { + return (label.priority < max) ? label.priority : max; + }, Infinity); + } + + addLabel (label) { + if (!this.findLabel(label)) { + this.labels.push(new ListLabel(label)); + } + } + + findLabel (findLabel) { + return this.labels.filter( label => label.title === findLabel.title )[0]; + } + + removeLabel (removeLabel) { + if (removeLabel) { + this.labels = this.labels.filter( label => removeLabel.title !== label.title ); + } + } + + removeLabels (labels) { + labels.forEach(this.removeLabel.bind(this)); + } + + getLists () { + return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) ); + } +} diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6 new file mode 100644 index 00000000000..583829552cd --- /dev/null +++ b/app/assets/javascripts/boards/models/label.js.es6 @@ -0,0 +1,10 @@ +class ListLabel { + constructor (obj) { + this.id = obj.id; + this.title = obj.title; + this.color = obj.color; + this.textColor = obj.text_color; + this.description = obj.description; + this.priority = (obj.priority !== null) ? obj.priority : Infinity; + } +} diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6 new file mode 100644 index 00000000000..5d0a561cdba --- /dev/null +++ b/app/assets/javascripts/boards/models/list.js.es6 @@ -0,0 +1,141 @@ +class List { + constructor (obj) { + this.id = obj.id; + this._uid = this.guid(); + this.position = obj.position; + this.title = obj.title; + this.type = obj.list_type; + this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1; + this.filters = gl.issueBoards.BoardsStore.state.filters; + this.page = 1; + this.loading = true; + this.loadingMore = false; + this.issues = []; + this.issuesSize = 0; + + if (obj.label) { + this.label = new ListLabel(obj.label); + } + + if (this.type !== 'blank' && this.id) { + this.getIssues(); + } + } + + guid() { + const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + } + + save () { + return gl.boardService.createList(this.label.id) + .then((resp) => { + const data = resp.json(); + + this.id = data.id; + this.type = data.list_type; + this.position = data.position; + + return this.getIssues(); + }); + } + + destroy () { + gl.issueBoards.BoardsStore.state.lists.$remove(this); + gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); + + gl.boardService.destroyList(this.id); + } + + update () { + gl.boardService.updateList(this.id, this.position); + } + + nextPage () { + if (this.issuesSize > this.issues.length) { + this.page++; + + return this.getIssues(false); + } + } + + getIssues (emptyIssues = true) { + const filters = this.filters; + let data = { page: this.page }; + + Object.keys(filters).forEach((key) => { data[key] = filters[key]; }); + + if (this.label) { + data.label_name = data.label_name.filter( label => label !== this.label.title ); + } + + if (emptyIssues) { + this.loading = true; + } + + return gl.boardService.getIssuesForList(this.id, data) + .then((resp) => { + const data = resp.json(); + this.loading = false; + this.issuesSize = data.size; + + if (emptyIssues) { + this.issues = []; + } + + this.createIssues(data.issues); + }); + } + + newIssue (issue) { + this.addIssue(issue); + this.issuesSize++; + + return gl.boardService.newIssue(this.id, issue) + .then((resp) => { + const data = resp.json(); + issue.id = data.iid; + }); + } + + createIssues (data) { + data.forEach((issueObj) => { + this.addIssue(new ListIssue(issueObj)); + }); + } + + addIssue (issue, listFrom) { + if (!this.findIssue(issue.id)) { + this.issues.push(issue); + + if (this.label) { + issue.addLabel(this.label); + } + + if (listFrom) { + this.issuesSize++; + gl.boardService.moveIssue(issue.id, listFrom.id, this.id) + .then(() => { + listFrom.getIssues(false); + }); + } + } + } + + findIssue (id) { + return this.issues.filter( issue => issue.id === id )[0]; + } + + removeIssue (removeIssue) { + this.issues = this.issues.filter((issue) => { + const matchesRemove = removeIssue.id === issue.id; + + if (matchesRemove) { + this.issuesSize--; + issue.removeLabel(this.label); + } + + return !matchesRemove; + }); + } +} diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6 new file mode 100644 index 00000000000..904b3a68507 --- /dev/null +++ b/app/assets/javascripts/boards/models/user.js.es6 @@ -0,0 +1,8 @@ +class ListUser { + constructor (user) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url; + } +} diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6 new file mode 100644 index 00000000000..b9c91cbf31e --- /dev/null +++ b/app/assets/javascripts/boards/services/board_service.js.es6 @@ -0,0 +1,67 @@ +class BoardService { + constructor (root, boardId) { + Vue.http.options.root = root; + + this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, { + generate: { + method: 'POST', + url: `${root}/${boardId}/lists/generate.json` + } + }); + this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {}); + this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}); + + Vue.http.interceptors.push((request, next) => { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + next(); + }); + } + + all () { + return this.lists.get(); + } + + generateDefaultLists () { + return this.lists.generate({}); + } + + createList (label_id) { + return this.lists.save({}, { + list: { + label_id + } + }); + } + + updateList (id, position) { + return this.lists.update({ id }, { + list: { + position + } + }); + } + + destroyList (id) { + return this.lists.delete({ id }); + } + + getIssuesForList (id, filter = {}) { + let data = { id }; + Object.keys(filter).forEach((key) => { data[key] = filter[key]; }); + + return this.issues.get(data); + } + + moveIssue (id, from_list_id, to_list_id) { + return this.issue.update({ id }, { + from_list_id, + to_list_id + }); + } + + newIssue (id, issue) { + return this.issues.save({ id }, { + issue + }); + } +}; diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6 new file mode 100644 index 00000000000..bd07ee0c161 --- /dev/null +++ b/app/assets/javascripts/boards/stores/boards_store.js.es6 @@ -0,0 +1,113 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardsStore = { + disabled: false, + state: {}, + moving: { + issue: {}, + list: {} + }, + create () { + this.state.lists = []; + this.state.filters = { + author_id: gl.utils.getParameterValues('author_id')[0], + assignee_id: gl.utils.getParameterValues('assignee_id')[0], + milestone_title: gl.utils.getParameterValues('milestone_title')[0], + label_name: gl.utils.getParameterValues('label_name[]'), + search: '' + }; + }, + addList (listObj) { + const list = new List(listObj); + this.state.lists.push(list); + + return list; + }, + new (listObj) { + const list = this.addList(listObj), + backlogList = this.findList('type', 'backlog', 'backlog'); + + list + .save() + .then(() => { + // Remove any new issues from the backlog + // as they will be visible in the new list + list.issues.forEach(backlogList.removeIssue.bind(backlogList)); + }); + this.removeBlankState(); + }, + updateNewListDropdown (listId) { + $(`.js-board-list-${listId}`).removeClass('is-active'); + }, + shouldAddBlankState () { + // Decide whether to add the blank state + return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]); + }, + addBlankState () { + if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; + + this.addList({ + id: 'blank', + list_type: 'blank', + title: 'Welcome to your Issue Board!', + position: 0 + }); + }, + removeBlankState () { + this.removeList('blank'); + + $.cookie('issue_board_welcome_hidden', 'true', { + expires: 365 * 10 + }); + }, + welcomeIsHidden () { + return $.cookie('issue_board_welcome_hidden') === 'true'; + }, + removeList (id, type = 'blank') { + const list = this.findList('id', id, type); + + if (!list) return; + + this.state.lists = this.state.lists.filter( list => list.id !== id ); + }, + moveList (listFrom, orderLists) { + orderLists.forEach((id, i) => { + const list = this.findList('id', parseInt(id)); + + list.position = i; + }); + listFrom.update(); + }, + moveIssueToList (listFrom, listTo, issue) { + const issueTo = listTo.findIssue(issue.id), + issueLists = issue.getLists(), + listLabels = issueLists.map( listIssue => listIssue.label ); + + // Add to new lists issues if it doesn't already exist + if (!issueTo) { + listTo.addIssue(issue, listFrom); + } + + if (listTo.type === 'done' && listFrom.type !== 'backlog') { + issueLists.forEach((list) => { + list.removeIssue(issue); + }) + issue.removeLabels(listLabels); + } else { + listFrom.removeIssue(issue); + } + }, + findList (key, val, type = 'label') { + return this.state.lists.filter((list) => { + const byType = type ? list['type'] === type : true; + + return list[key] === val && byType; + })[0]; + }, + updateFiltersUrl () { + history.pushState(null, null, `?${$.param(this.state.filters)}`); + } + }; +})(); diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/boards/test_utils/simulate_drag.js new file mode 100644 index 00000000000..75f8b730195 --- /dev/null +++ b/app/assets/javascripts/boards/test_utils/simulate_drag.js @@ -0,0 +1,119 @@ +(function () { + 'use strict'; + + function simulateEvent(el, type, options) { + var event; + if (!el) return; + var ownerDocument = el.ownerDocument; + + options = options || {}; + + if (/^mouse/.test(type)) { + event = ownerDocument.createEvent('MouseEvents'); + event.initMouseEvent(type, true, true, ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + } else { + event = ownerDocument.createEvent('CustomEvent'); + + event.initCustomEvent(type, true, true, ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + + event.dataTransfer = { + data: {}, + + setData: function (type, val) { + this.data[type] = val; + }, + + getData: function (type) { + return this.data[type]; + } + }; + } + + if (el.dispatchEvent) { + el.dispatchEvent(event); + } else if (el.fireEvent) { + el.fireEvent('on' + type, event); + } + + return event; + } + + function getTraget(target) { + var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; + var children = el.children; + + return ( + children[target.index] || + children[target.index === 'first' ? 0 : -1] || + children[target.index === 'last' ? children.length - 1 : -1] + ); + } + + function getRect(el) { + var rect = el.getBoundingClientRect(); + var width = rect.right - rect.left; + var height = rect.bottom - rect.top; + + return { + x: rect.left, + y: rect.top, + cx: rect.left + width / 2, + cy: rect.top + height / 2, + w: width, + h: height, + hw: width / 2, + wh: height / 2 + }; + } + + function simulateDrag(options, callback) { + options.to.el = options.to.el || options.from.el; + + var fromEl = getTraget(options.from); + var toEl = getTraget(options.to); + var scrollable = options.scrollable; + + var fromRect = getRect(fromEl); + var toRect = getRect(toEl); + + var startTime = new Date().getTime(); + var duration = options.duration || 1000; + simulateEvent(fromEl, 'mousedown', {button: 0}); + options.ontap && options.ontap(); + window.SIMULATE_DRAG_ACTIVE = 1; + + var dragInterval = setInterval(function loop() { + var progress = (new Date().getTime() - startTime) / duration; + var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft; + var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop; + var overEl = fromEl.ownerDocument.elementFromPoint(x, y); + + simulateEvent(overEl, 'mousemove', { + clientX: x, + clientY: y + }); + + if (progress >= 1) { + options.ondragend && options.ondragend(); + simulateEvent(toEl, 'mouseup'); + clearInterval(dragInterval); + window.SIMULATE_DRAG_ACTIVE = 0; + } + }, 100); + + return { + target: fromEl, + fromList: fromEl.parentNode, + toList: toEl.parentNode + }; + } + + + // Export + window.simulateEvent = simulateEvent; + window.simulateDrag = simulateDrag; +})(); diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..b5ff3a81ed5 --- /dev/null +++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 @@ -0,0 +1,7 @@ +Vue.http.interceptors.push((request, next) => { + Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; + + next(function (response) { + Vue.activeResources--; + }); +}); diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js index 1e0148e5798..5fef9725178 100644 --- a/app/assets/javascripts/breakpoints.js +++ b/app/assets/javascripts/breakpoints.js @@ -23,6 +23,7 @@ if ($(allDeviceSelector.join(",")).length) { return; } + // Create all the elements els = $.map(BREAKPOINTS, function(breakpoint) { return "<div class='device-" + breakpoint + " visible-" + breakpoint + "'></div>"; }); @@ -40,6 +41,7 @@ BreakpointInstance.prototype.getBreakpointSize = function() { var $visibleDevice; $visibleDevice = this.visibleDevice; + // the page refreshed via turbolinks if (!$visibleDevice().length) { this.setup(); } diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 3d9b824d406..f336bfc36d6 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -6,23 +6,32 @@ Build.state = null; - function Build(page_url, build_url, build_status, state1) { - this.page_url = page_url; - this.build_url = build_url; - this.build_status = build_status; - this.state = state1; + function Build(options) { + this.page_url = options.page_url; + this.build_url = options.build_url; + this.build_status = options.build_status; + this.state = options.state1; + this.build_stage = options.build_stage; this.hideSidebar = bind(this.hideSidebar, this); this.toggleSidebar = bind(this.toggleSidebar, this); + this.updateDropdown = bind(this.updateDropdown, this); clearInterval(Build.interval); + // Init breakpoint checker this.bp = Breakpoints.get(); - this.hideSidebar(); $('.js-build-sidebar').niceScroll(); + + this.populateJobs(this.build_stage); + this.updateStageDropdownText(this.build_stage); + this.hideSidebar(); + $(document).off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); $(window).off('resize.build').on('resize.build', this.hideSidebar); + $(document).off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); + $('#js-build-scroll > a').off('click').on('click', this.stepTrace); this.updateArtifactRemoveDate(); if ($('#build-trace').length) { this.getInitialBuildTrace(); - this.initScrollButtonAffix(); + this.initScrollButtons(); } if (this.build_status === "running" || this.build_status === "pending") { $('#autoscroll-button').on('click', function() { @@ -35,6 +44,9 @@ $(this).data("state", "enabled"); return $(this).text("disable autoscroll"); } + // + // Bind autoscroll button to follow build output + // }); Build.interval = setInterval((function(_this) { return function() { @@ -42,17 +54,23 @@ return _this.getBuildTrace(); } }; + // + // Check for new build output if user still watching build page + // Only valid for runnig build when output changes during time + // })(this), 4000); } } Build.prototype.getInitialBuildTrace = function() { + var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'] + return $.ajax({ url: this.build_url, dataType: 'json', success: function(build_data) { $('.js-build-output').html(build_data.trace_html); - if (build_data.status === 'success' || build_data.status === 'failed') { + if (removeRefreshStatuses.indexOf(build_data.status) >= 0) { return $('.js-build-refresh').remove(); } } @@ -89,7 +107,7 @@ } }; - Build.prototype.initScrollButtonAffix = function() { + Build.prototype.initScrollButtons = function() { var $body, $buildScroll, $buildTrace; $buildScroll = $('#js-build-scroll'); $body = $('body'); @@ -128,10 +146,34 @@ $date = $('.js-artifacts-remove'); if ($date.length) { date = $date.text(); - return $date.text($.timefor(new Date(date.replace(/-/g, '/')), ' ')); + return $date.text($.timefor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); } }; + Build.prototype.populateJobs = function(stage) { + $('.build-job').hide(); + $('.build-job[data-stage="' + stage + '"]').show(); + }; + + Build.prototype.updateStageDropdownText = function(stage) { + $('.stage-selection').text(stage); + }; + + Build.prototype.updateDropdown = function(e) { + e.preventDefault(); + var stage = e.currentTarget.text; + this.updateStageDropdownText(stage); + this.populateJobs(stage); + }; + + Build.prototype.stepTrace = function(e) { + e.preventDefault(); + $currentTarget = $(e.currentTarget); + $.scrollTo($currentTarget.attr('href'), { + offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()) + }); + }; + return Build; })(); diff --git a/app/assets/javascripts/build_variables.js.es6 b/app/assets/javascripts/build_variables.js.es6 new file mode 100644 index 00000000000..8d3e29794a1 --- /dev/null +++ b/app/assets/javascripts/build_variables.js.es6 @@ -0,0 +1,6 @@ +$(function(){ + $('.reveal-variables').off('click').on('click',function(){ + $('.js-build').toggle().niceScroll(); + $(this).hide(); + }); +}); diff --git a/app/assets/javascripts/commit/image-file.js b/app/assets/javascripts/commit/image_file.js index c0d0b2d049f..e893491b19b 100644 --- a/app/assets/javascripts/commit/image-file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -2,6 +2,7 @@ this.ImageFile = (function() { var prepareFrames; + // Width where images must fits in, for 2-up this gets divided by 2 ImageFile.availWidth = 900; ImageFile.viewModes = ['two-up', 'swipe']; @@ -9,6 +10,7 @@ function ImageFile(file) { this.file = file; this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), (function(_this) { + // Determine if old and new file has same dimensions, if not show 'two-up' view return function(deletedWidth, deletedHeight) { return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function(width, height) { if (width === deletedWidth && height === deletedHeight) { diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 37f168c5190..9132089adcd 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -45,6 +45,7 @@ CommitsList.content.html(data.html); return history.replaceState({ page: commitsUrl + // Change url so if user reload a page - search results are saved }, document.title, commitsUrl); }, dataType: "json" diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 4e3a28cd163..294d2c9052c 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -23,8 +23,9 @@ selectable: true, filterable: true, filterByText: true, - fieldName: $dropdown.attr('name'), - filterInput: 'input[type="text"]', + toggleLabel: true, + fieldName: $dropdown.data('field-name'), + filterInput: 'input[type="search"]', renderRow: function(ref) { var link; if (ref.header != null) { diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js index c82798cc6a5..e23bda2fa4e 100644 --- a/app/assets/javascripts/copy_to_clipboard.js +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -6,14 +6,19 @@ genericSuccess = function(e) { showTooltip(e.trigger, 'Copied!'); + // Clear the selection and blur the trigger so it loses its border e.clearSelection(); return $(e.trigger).blur(); }; + // Safari doesn't support `execCommand`, so instead we inform the user to + // copy manually. + // + // See http://clipboardjs.com/#browser-support genericError = function(e) { var key; if (/Mac/i.test(navigator.userAgent)) { - key = '⌘'; + key = '⌘'; // Command } else { key = 'Ctrl'; } @@ -21,19 +26,20 @@ }; showTooltip = function(target, title) { - return $(target).tooltip({ - container: 'body', - html: 'true', - placement: 'auto bottom', - title: title, - trigger: 'manual' - }).tooltip('show').one('mouseleave', function() { - return $(this).tooltip('hide'); - }); + var $target = $(target); + var originalTitle = $target.data('original-title'); + + $target + .attr('title', 'Copied!') + .tooltip('fixTitle') + .tooltip('show') + .attr('title', originalTitle) + .tooltip('fixTitle'); }; $(function() { var clipboard; + clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); clipboard.on('success', genericSuccess); return clipboard.on('error', genericError); diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6 new file mode 100644 index 00000000000..c5f8c29242d --- /dev/null +++ b/app/assets/javascripts/create_label.js.es6 @@ -0,0 +1,127 @@ +(function (w) { + class CreateLabelDropdown { + constructor ($el, namespacePath, projectPath) { + this.$el = $el; + this.namespacePath = namespacePath; + this.projectPath = projectPath; + this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown')); + this.$cancelButton = $('.js-cancel-label-btn', this.$el); + this.$newLabelField = $('#new_label_name', this.$el); + this.$newColorField = $('#new_label_color', this.$el); + this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); + this.$newLabelError = $('.js-label-error', this.$el); + this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); + this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); + + this.$newLabelError.hide(); + this.$newLabelCreateButton.disable(); + + this.cleanBinding(); + this.addBinding(); + } + + cleanBinding () { + this.$colorSuggestions.off('click'); + this.$newLabelField.off('keyup change'); + this.$newColorField.off('keyup change'); + this.$dropdownBack.off('click'); + this.$cancelButton.off('click'); + this.$newLabelCreateButton.off('click'); + } + + addBinding () { + const self = this; + + this.$colorSuggestions.on('click', function (e) { + const $this = $(this); + self.addColorValue(e, $this); + }); + + this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this)); + this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this)); + + this.$dropdownBack.on('click', this.resetForm.bind(this)); + + this.$cancelButton.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + self.resetForm(); + self.$dropdownBack.trigger('click'); + }); + + this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); + } + + addColorValue (e, $this) { + e.preventDefault(); + e.stopPropagation(); + + this.$newColorField.val($this.data('color')).trigger('change'); + this.$colorPreview + .css('background-color', $this.data('color')) + .parent() + .addClass('is-active'); + } + + enableLabelCreateButton () { + if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { + this.$newLabelError.hide(); + this.$newLabelCreateButton.enable(); + } else { + this.$newLabelCreateButton.disable(); + } + } + + resetForm () { + this.$newLabelField + .val('') + .trigger('change'); + + this.$newColorField + .val('') + .trigger('change'); + + this.$colorPreview + .css('background-color', '') + .parent() + .removeClass('is-active'); + } + + saveLabel (e) { + e.preventDefault(); + e.stopPropagation(); + + Api.newLabel(this.namespacePath, this.projectPath, { + title: this.$newLabelField.val(), + color: this.$newColorField.val() + }, (label) => { + this.$newLabelCreateButton.enable(); + + if (label.message) { + let errors; + + if (typeof label.message === 'string') { + errors = label.message; + } else { + errors = label.message.map(function (value, key) { + return key + " " + value[0]; + }).join("<br/>"); + } + + this.$newLabelError + .html(errors) + .show(); + } else { + this.$dropdownBack.trigger('click'); + } + }); + } + } + + if (!w.gl) { + w.gl = {}; + } + + gl.CreateLabelDropdown = CreateLabelDropdown; +})(window); diff --git a/app/assets/javascripts/cycle_analytics.js.es6 b/app/assets/javascripts/cycle_analytics.js.es6 new file mode 100644 index 00000000000..cd9886ba58d --- /dev/null +++ b/app/assets/javascripts/cycle_analytics.js.es6 @@ -0,0 +1,93 @@ +((global) => { + + const COOKIE_NAME = 'cycle_analytics_help_dismissed'; + const store = gl.cycleAnalyticsStore = { + isLoading: true, + hasError: false, + isHelpDismissed: $.cookie(COOKIE_NAME), + analytics: {} + }; + + gl.CycleAnalytics = class CycleAnalytics { + constructor() { + const that = this; + + this.vue = new Vue({ + el: '#cycle-analytics', + name: 'CycleAnalytics', + created: this.fetchData(), + data: store, + methods: { + dismissLanding() { + that.dismissLanding(); + } + } + }); + } + + fetchData(options) { + store.isLoading = true; + options = options || { startDate: 30 }; + + $.ajax({ + url: $('#cycle-analytics').data('request-path'), + method: 'GET', + dataType: 'json', + contentType: 'application/json', + data: { start_date: options.startDate } + }).done((data) => { + this.decorateData(data); + this.initDropdown(); + }) + .error((data) => { + this.handleError(data); + }) + .always(() => { + store.isLoading = false; + }) + } + + decorateData(data) { + data.summary = data.summary || []; + data.stats = data.stats || []; + + data.summary.forEach((item) => { + item.value = item.value || '-'; + }); + + data.stats.forEach((item) => { + item.value = item.value || '- - -'; + }); + + store.analytics = data; + } + + handleError(data) { + store.hasError = true; + new Flash('There was an error while fetching cycle analytics data.', 'alert'); + } + + dismissLanding() { + store.isHelpDismissed = true; + $.cookie(COOKIE_NAME, true, { + path: gon.relative_url_root || '/' + }); + } + + initDropdown() { + const $dropdown = $('.js-ca-dropdown'); + const $label = $dropdown.find('.dropdown-label'); + + $dropdown.find('li a').off('click').on('click', (e) => { + e.preventDefault(); + const $target = $(e.currentTarget); + const value = $target.data('value'); + + $label.text($target.text().trim()); + this.fetchData({ startDate: value }); + }) + } + + } + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 298f3852085..8086c10ad6b 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -7,10 +7,13 @@ function Diff() { $('.files .diff-file').singleFileDiff(); this.filesCommentButton = $('.files .diff-file').filesCommentButton(); + if (this.diffViewType() === 'parallel') { + $('.content-wrapper .container-fluid').removeClass('container-limited'); + } $(document).off('click', '.js-unfold'); $(document).on('click', '.js-unfold', (function(_this) { return function(event) { - var line_number, link, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom; + var line_number, link, file, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom; target = $(event.target); unfoldBottom = target.hasClass('js-unfold-bottom'); unfold = true; @@ -31,14 +34,19 @@ unfold = false; } } - link = target.parents('.diff-file').attr('data-blob-diff-path'); + file = target.parents('.diff-file'); + link = file.data('blob-diff-path'); params = { since: since, to: to, bottom: unfoldBottom, offset: offset, unfold: unfold, - indent: 1 + // indent is used to compensate for single space indent to fit + // '+' and '-' prepended to diff lines, + // see https://gitlab.com/gitlab-org/gitlab-ce/issues/707 + indent: 1, + view: file.data('view') }; return $.get(link, params, function(response) { return target.parent().replaceWith(response); @@ -47,27 +55,18 @@ })(this)); } + Diff.prototype.diffViewType = function() { + return $('.inline-parallel-buttons a.active').data('view-type'); + } + Diff.prototype.lineNumbers = function(line) { - var i, l, len, line_number, line_numbers, lines, results; if (!line.children().length) { return [0, 0]; } - lines = line.children().slice(0, 2); - line_numbers = (function() { - var i, len, results; - results = []; - for (i = 0, len = lines.length; i < len; i++) { - l = lines[i]; - results.push($(l).attr('data-linenumber')); - } - return results; - })(); - results = []; - for (i = 0, len = line_numbers.length; i < len; i++) { - line_number = line_numbers[i]; - results.push(parseInt(line_number)); - } - return results; + + return line.find('.diff-line-num').map(function() { + return parseInt($(this).data('linenumber')); + }); }; return Diff; diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 new file mode 100644 index 00000000000..48bc7d77805 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 @@ -0,0 +1,49 @@ +((w) => { + w.CommentAndResolveBtn = Vue.extend({ + props: { + discussionId: String, + textareaIsEmpty: Boolean + }, + computed: { + discussion: function () { + return CommentsStore.state[this.discussionId]; + }, + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } + }, + isDiscussionResolved: function () { + return this.discussion.isResolved(); + }, + buttonText: function () { + if (this.isDiscussionResolved) { + if (this.textareaIsEmpty) { + return "Unresolve discussion"; + } else { + return "Comment & unresolve discussion"; + } + } else { + if (this.textareaIsEmpty) { + return "Resolve discussion"; + } else { + return "Comment & resolve discussion"; + } + } + } + }, + ready: function () { + const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + this.textareaIsEmpty = $textarea.val() === ''; + + $textarea.on('input.comment-and-resolve-btn', () => { + this.textareaIsEmpty = $textarea.val() === ''; + }); + }, + destroyed: function () { + $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 new file mode 100644 index 00000000000..ad80d1118df --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 @@ -0,0 +1,188 @@ +(() => { + JumpToDiscussion = Vue.extend({ + mixins: [DiscussionMixins], + props: { + discussionId: String + }, + data: function () { + return { + discussions: CommentsStore.state, + }; + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + allResolved: function () { + return this.unresolvedDiscussionCount === 0; + }, + showButton: function () { + if (this.discussionId) { + if (this.unresolvedDiscussionCount > 1) { + return true; + } else { + return this.discussionId !== this.lastResolvedId; + } + } else { + return this.unresolvedDiscussionCount >= 1; + } + }, + lastResolvedId: function () { + let lastId; + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (!discussion.isResolved()) { + lastId = discussion.id; + } + } + return lastId; + } + }, + methods: { + jumpToNextUnresolvedDiscussion: function () { + let discussionsSelector, + discussionIdsInScope, + firstUnresolvedDiscussionId, + nextUnresolvedDiscussionId, + activeTab = window.mrTabs.currentAction, + hasDiscussionsToJumpTo = true, + jumpToFirstDiscussion = !this.discussionId; + + const discussionIdsForElements = function(elements) { + return elements.map(function() { + return $(this).attr('data-discussion-id'); + }).toArray(); + }; + + const discussions = this.discussions; + + if (activeTab === 'diffs') { + discussionsSelector = '.diffs .notes[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + + let unresolvedDiscussionCount = 0; + + for (let i = 0; i < discussionIdsInScope.length; i++) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + if (discussion && !discussion.isResolved()) { + unresolvedDiscussionCount++; + } + } + + if (this.discussionId && !this.discussion.isResolved()) { + // If this is the last unresolved discussion on the diffs tab, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 1) { + hasDiscussionsToJumpTo = false; + } + } else { + // If there are no unresolved discussions on the diffs tab at all, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 0) { + hasDiscussionsToJumpTo = false; + } + } + } else if (activeTab !== 'notes') { + // If we are on the commits or builds tabs, + // there are no discussions to jump to. + hasDiscussionsToJumpTo = false; + } + + if (!hasDiscussionsToJumpTo) { + // If there are no discussions to jump to on the current page, + // switch to the notes tab and jump to the first disucssion there. + window.mrTabs.activateTab('notes'); + activeTab = 'notes'; + jumpToFirstDiscussion = true; + } + + if (activeTab === 'notes') { + discussionsSelector = '.discussion[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + } + + let currentDiscussionFound = false; + for (let i = 0; i < discussionIdsInScope.length; i++) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + + if (!discussion) { + // Discussions for comments on commits in this MR don't have a resolved status. + continue; + } + + if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { + firstUnresolvedDiscussionId = discussionId; + + if (jumpToFirstDiscussion) { + break; + } + } + + if (!jumpToFirstDiscussion) { + if (currentDiscussionFound) { + if (!discussion.isResolved()) { + nextUnresolvedDiscussionId = discussionId; + break; + } + else { + continue; + } + } + + if (discussionId === this.discussionId) { + currentDiscussionFound = true; + } + } + } + + nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; + + if (!nextUnresolvedDiscussionId) { + return; + } + + let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); + + if (activeTab === 'notes') { + $target = $target.closest('.note-discussion'); + + // If the next discussion is closed, toggle it open. + if ($target.find('.js-toggle-content').is(':hidden')) { + $target.find('.js-toggle-button i').trigger('click') + } + } else if (activeTab === 'diffs') { + // Resolved discussions are hidden in the diffs tab by default. + // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. + // When jumping between unresolved discussions on the diffs tab, we show them. + $target.closest(".content").show(); + + $target = $target.closest("tr.notes_holder"); + $target.show(); + + // If we are on the diffs tab, we don't scroll to the discussion itself, but to + // 4 diff lines above it: the line the discussion was in response to + 3 context + let prevEl; + for (let i = 0; i < 4; i++) { + prevEl = $target.prev(); + + // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. + if (!prevEl.hasClass("line_holder")) { + break; + } + + $target = prevEl; + } + } + + $.scrollTo($target, { + offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()) + }); + } + } + }); + + Vue.component('jump-to-discussion', JumpToDiscussion); +})(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 new file mode 100644 index 00000000000..cdedfd1af15 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 @@ -0,0 +1,103 @@ +((w) => { + w.ResolveBtn = Vue.extend({ + props: { + noteId: Number, + discussionId: String, + resolved: Boolean, + projectPath: String, + canResolve: Boolean, + resolvedBy: String + }, + data: function () { + return { + discussions: CommentsStore.state, + loading: false + }; + }, + watch: { + 'discussions': { + handler: 'updateTooltip', + deep: true + } + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + note: function () { + if (this.discussion) { + return this.discussion.getNote(this.noteId); + } else { + return undefined; + } + }, + buttonText: function () { + if (this.isResolved) { + return `Resolved by ${this.resolvedByName}`; + } else if (this.canResolve) { + return 'Mark as resolved'; + } else { + return 'Unable to resolve'; + } + }, + isResolved: function () { + if (this.note) { + return this.note.resolved; + } else { + return false; + } + }, + resolvedByName: function () { + return this.note.resolved_by; + }, + }, + methods: { + updateTooltip: function () { + $(this.$els.button) + .tooltip('hide') + .tooltip('fixTitle'); + }, + resolve: function () { + if (!this.canResolve) return; + + let promise; + this.loading = true; + + if (this.isResolved) { + promise = ResolveService + .unresolve(this.projectPath, this.noteId); + } else { + promise = ResolveService + .resolve(this.projectPath, this.noteId); + } + + promise.then((response) => { + this.loading = false; + + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; + + CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); + this.discussion.updateHeadline(data); + } else { + new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); + } + + this.$nextTick(this.updateTooltip); + }); + } + }, + compiled: function () { + $(this.$els.button).tooltip({ + container: 'body' + }); + }, + beforeDestroy: function () { + CommentsStore.delete(this.discussionId, this.noteId); + }, + created: function () { + CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy); + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 new file mode 100644 index 00000000000..9e383b14a3e --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 @@ -0,0 +1,18 @@ +((w) => { + w.ResolveCount = Vue.extend({ + mixins: [DiscussionMixins], + props: { + loggedOut: Boolean + }, + data: function () { + return { + discussions: CommentsStore.state + }; + }, + computed: { + allResolved: function () { + return this.resolvedDiscussionCount === this.discussionCount; + } + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 new file mode 100644 index 00000000000..0a617034502 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 @@ -0,0 +1,56 @@ +((w) => { + w.ResolveDiscussionBtn = Vue.extend({ + props: { + discussionId: String, + mergeRequestId: Number, + projectPath: String, + canResolve: Boolean, + }, + data: function() { + return { + discussions: CommentsStore.state + }; + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } + }, + isDiscussionResolved: function () { + if (this.discussion) { + return this.discussion.isResolved(); + } else { + return false; + } + }, + buttonText: function () { + if (this.isDiscussionResolved) { + return "Unresolve discussion"; + } else { + return "Resolve discussion"; + } + }, + loading: function () { + if (this.discussion) { + return this.discussion.loading; + } else { + return false; + } + } + }, + methods: { + resolve: function () { + ResolveService.toggleResolveForDiscussion(this.projectPath, this.mergeRequestId, this.discussionId); + } + }, + created: function () { + CommentsStore.createDiscussion(this.discussionId, this.canResolve); + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 new file mode 100644 index 00000000000..22d9cf6c857 --- /dev/null +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 @@ -0,0 +1,35 @@ +//= require vue +//= require vue-resource +//= require_directory ./models +//= require_directory ./stores +//= require_directory ./services +//= require_directory ./mixins +//= require_directory ./components + +$(() => { + window.DiffNotesApp = new Vue({ + el: '#diff-notes-app', + components: { + 'resolve-btn': ResolveBtn, + 'resolve-discussion-btn': ResolveDiscussionBtn, + 'comment-and-resolve-btn': CommentAndResolveBtn + }, + methods: { + compileComponents: function () { + const $components = $('resolve-btn, resolve-discussion-btn, jump-to-discussion'); + if ($components.length) { + $components.each(function () { + DiffNotesApp.$compile($(this).get(0)); + }); + } + } + } + }); + + new Vue({ + el: '#resolve-count-app', + components: { + 'resolve-count': ResolveCount + } + }); +}); diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 new file mode 100644 index 00000000000..a05f885201d --- /dev/null +++ b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 @@ -0,0 +1,35 @@ +((w) => { + w.DiscussionMixins = { + computed: { + discussionCount: function () { + return Object.keys(this.discussions).length; + }, + resolvedDiscussionCount: function () { + let resolvedCount = 0; + + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (discussion.isResolved()) { + resolvedCount++; + } + } + + return resolvedCount; + }, + unresolvedDiscussionCount: function () { + let unresolvedCount = 0; + + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (!discussion.isResolved()) { + unresolvedCount++; + } + } + + return unresolvedCount; + } + } + }; +})(window); diff --git a/app/assets/javascripts/diff_notes/models/discussion.js.es6 b/app/assets/javascripts/diff_notes/models/discussion.js.es6 new file mode 100644 index 00000000000..488714e4870 --- /dev/null +++ b/app/assets/javascripts/diff_notes/models/discussion.js.es6 @@ -0,0 +1,87 @@ +class DiscussionModel { + constructor (discussionId) { + this.id = discussionId; + this.notes = {}; + this.loading = false; + this.canResolve = false; + } + + createNote (noteId, canResolve, resolved, resolved_by) { + Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by)); + } + + deleteNote (noteId) { + Vue.delete(this.notes, noteId); + } + + getNote (noteId) { + return this.notes[noteId]; + } + + notesCount() { + return Object.keys(this.notes).length; + } + + isResolved () { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (!note.resolved) { + return false; + } + } + return true; + } + + resolveAllNotes (resolved_by) { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (!note.resolved) { + note.resolved = true; + note.resolved_by = resolved_by; + } + } + } + + unResolveAllNotes () { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (note.resolved) { + note.resolved = false; + note.resolved_by = null; + } + } + } + + updateHeadline (data) { + const $discussionHeadline = $(`.discussion[data-discussion-id="${this.id}"] .js-discussion-headline`); + + if (data.discussion_headline_html) { + if ($discussionHeadline.length) { + $discussionHeadline.replaceWith(data.discussion_headline_html); + } else { + $(`.discussion[data-discussion-id="${this.id}"] .discussion-header`).append(data.discussion_headline_html); + } + } else { + $discussionHeadline.remove(); + } + } + + isResolvable () { + if (!this.canResolve) { + return false; + } + + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (note.canResolve) { + return true; + } + } + + return false; + } +} diff --git a/app/assets/javascripts/diff_notes/models/note.js.es6 b/app/assets/javascripts/diff_notes/models/note.js.es6 new file mode 100644 index 00000000000..f2d2d389c38 --- /dev/null +++ b/app/assets/javascripts/diff_notes/models/note.js.es6 @@ -0,0 +1,9 @@ +class NoteModel { + constructor (discussionId, noteId, canResolve, resolved, resolved_by) { + this.discussionId = discussionId; + this.id = noteId; + this.canResolve = canResolve; + this.resolved = resolved; + this.resolved_by = resolved_by; + } +} diff --git a/app/assets/javascripts/diff_notes/services/resolve.js.es6 b/app/assets/javascripts/diff_notes/services/resolve.js.es6 new file mode 100644 index 00000000000..2a55f739b31 --- /dev/null +++ b/app/assets/javascripts/diff_notes/services/resolve.js.es6 @@ -0,0 +1,88 @@ +((w) => { + class ResolveServiceClass { + constructor() { + this.noteResource = Vue.resource('notes{/noteId}/resolve'); + this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve'); + } + + setCSRF() { + Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken(); + } + + prepareRequest(root) { + this.setCSRF(); + Vue.http.options.root = root; + } + + resolve(projectPath, noteId) { + this.prepareRequest(projectPath); + + return this.noteResource.save({ noteId }, {}); + } + + unresolve(projectPath, noteId) { + this.prepareRequest(projectPath); + + return this.noteResource.delete({ noteId }, {}); + } + + toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId], + isResolved = discussion.isResolved(); + let promise; + + if (isResolved) { + promise = this.unResolveAll(projectPath, mergeRequestId, discussionId); + } else { + promise = this.resolveAll(projectPath, mergeRequestId, discussionId); + } + + promise.then((response) => { + discussion.loading = false; + + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; + + if (isResolved) { + discussion.unResolveAllNotes(); + } else { + discussion.resolveAllNotes(resolved_by); + } + + discussion.updateHeadline(data); + } else { + new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); + } + }) + } + + resolveAll(projectPath, mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + + this.prepareRequest(projectPath); + + discussion.loading = true; + + return this.discussionResource.save({ + mergeRequestId, + discussionId + }, {}); + } + + unResolveAll(projectPath, mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + + this.prepareRequest(projectPath); + + discussion.loading = true; + + return this.discussionResource.delete({ + mergeRequestId, + discussionId + }, {}); + } + } + + w.ResolveService = new ResolveServiceClass(); +})(window); diff --git a/app/assets/javascripts/diff_notes/stores/comments.js.es6 b/app/assets/javascripts/diff_notes/stores/comments.js.es6 new file mode 100644 index 00000000000..69522e1dac5 --- /dev/null +++ b/app/assets/javascripts/diff_notes/stores/comments.js.es6 @@ -0,0 +1,53 @@ +((w) => { + w.CommentsStore = { + state: {}, + get: function (discussionId, noteId) { + return this.state[discussionId].getNote(noteId); + }, + createDiscussion: function (discussionId, canResolve) { + let discussion = this.state[discussionId]; + if (!this.state[discussionId]) { + discussion = new DiscussionModel(discussionId); + Vue.set(this.state, discussionId, discussion); + } + + if (canResolve !== undefined) { + discussion.canResolve = canResolve; + } + + return discussion; + }, + create: function (discussionId, noteId, canResolve, resolved, resolved_by) { + const discussion = this.createDiscussion(discussionId); + + discussion.createNote(noteId, canResolve, resolved, resolved_by); + }, + update: function (discussionId, noteId, resolved, resolved_by) { + const discussion = this.state[discussionId]; + const note = discussion.getNote(noteId); + note.resolved = resolved; + note.resolved_by = resolved_by; + }, + delete: function (discussionId, noteId) { + const discussion = this.state[discussionId]; + discussion.deleteNote(noteId); + + if (discussion.notesCount() === 0) { + Vue.delete(this.state, discussionId); + } + }, + unresolvedDiscussionIds: function () { + let ids = []; + + for (const discussionId in this.state) { + const discussion = this.state[discussionId]; + + if (!discussion.isResolved()) { + ids.push(discussion.id); + } + } + + return ids; + } + }; +})(window); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 9e6901962c6..f3ef13ce20e 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -20,9 +20,14 @@ path = page.split(':'); shortcut_handler = null; switch (page) { + case 'projects:boards:show': + case 'projects:boards:index': + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:merge_requests:index': case 'projects:issues:index': Issuable.init(); - new IssuableBulkActions(); + new gl.IssuableBulkActions(); shortcut_handler = new ShortcutsNavigation(); break; case 'projects:issues:show': @@ -36,7 +41,7 @@ new Milestone(); break; case 'dashboard:todos:index': - new Todos(); + new gl.Todos(); break; case 'projects:milestones:new': case 'projects:milestones:edit': @@ -55,6 +60,9 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.issue-form')); new IssuableForm($('.issue-form')); + new LabelsSelect(); + new MilestoneSelect(); + new gl.IssuableTemplateSelectors(); break; case 'projects:merge_requests:new': case 'projects:merge_requests:edit': @@ -62,6 +70,9 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); + new LabelsSelect(); + new MilestoneSelect(); + new gl.IssuableTemplateSelectors(); break; case 'projects:tags:new': new ZenMode(); @@ -86,6 +97,9 @@ new ZenMode(); new MergedButtons(); break; + case "projects:merge_requests:conflicts": + window.mcui = new MergeConflictResolver() + break; case 'projects:merge_requests:index': shortcut_handler = new ShortcutsNavigation(); Issuable.init(); @@ -113,6 +127,9 @@ new TreeView(); } break; + case 'projects:pipelines:show': + new gl.Pipelines(); + break; case 'groups:activity': new Activities(); break; @@ -122,10 +139,12 @@ new NotificationsDropdown(); break; case 'groups:group_members:index': + new gl.MemberExpirationDate(); new GroupMembers(); new UsersSelect(); break; case 'projects:project_members:index': + new gl.MemberExpirationDate(); new ProjectMembers(); new UsersSelect(); break; @@ -154,10 +173,12 @@ break; case 'projects:labels:index': if ($('.prioritized-labels').length) { - new LabelManager(); + new gl.LabelManager(); } break; case 'projects:network:show': + // Ensure we don't create a particular shortcut handler here. This is + // already created, where the network graph is created. shortcut_handler = true; break; case 'projects:forks:new': @@ -167,14 +188,18 @@ new BuildArtifacts(); break; case 'projects:group_links:index': + new gl.MemberExpirationDate(); new GroupsSelect(); break; case 'search:show': new Search(); break; case 'projects:protected_branches:index': - new ProtectedBranchesAccessSelect($(".new_protected_branch"), false, true); - new ProtectedBranchesAccessSelect($(".protected-branches-list"), true, false); + new gl.ProtectedBranchCreate(); + new gl.ProtectedBranchEditList(); + break; + case 'projects:cycle_analytics:show': + new gl.CycleAnalytics(); break; } switch (path.first()) { @@ -186,6 +211,16 @@ break; case 'projects': new NamespaceSelects(); + break; + case 'labels': + switch (path[2]) { + case 'new': + case 'edit': + new Labels(); + } + case 'abuse_reports': + new gl.AbuseReports(); + break; } break; case 'dashboard': @@ -211,6 +246,7 @@ new ProjectNew(); break; case 'show': + new Star(); new ProjectNew(); new ProjectShow(); new NotificationsDropdown(); @@ -242,14 +278,16 @@ shortcut_handler = new ShortcutsNavigation(); } } + // If we haven't installed a custom shortcut handler, install the default one if (!shortcut_handler) { return new Shortcuts(); } }; Dispatcher.prototype.initSearch = function() { + // Only when search form is present if ($('.search').length) { - return new SearchAutocomplete(); + return new gl.SearchAutocomplete(); } }; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 288cce04f87..4a6fea929c7 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,5 +1,5 @@ -/*= require markdown_preview */ +/*= require preview_markdown */ (function() { this.DropzoneInput = (function() { diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 5a725a41fd1..bf68b7e3a9b 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -2,6 +2,7 @@ this.DueDateSelect = (function() { function DueDateSelect() { var $datePicker, $dueDate, $loading; + // Milestone edit/new form $datePicker = $('.datepicker'); if ($datePicker.length) { $dueDate = $('#milestone_due_date'); @@ -16,6 +17,7 @@ e.preventDefault(); return $.datepicker._clearDate($datePicker); }); + // Issuable sidebar $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); $('.js-due-date-select').each(function(i, dropdown) { var $block, $dropdown, $dropdownParent, $selectbox, $sidebarValue, $value, $valueContent, abilityName, addDueDate, fieldName, issueUpdateURL; @@ -38,6 +40,7 @@ }); addDueDate = function(isDropdown) { var data, date, mediumDate, value; + // Create the post date value = $("input[name='" + fieldName + "']").val(); if (value !== '') { date = new Date(value.replace(new RegExp('-', 'g'), ',')); diff --git a/app/assets/javascripts/extensions/jquery.js b/app/assets/javascripts/extensions/jquery.js index ae3dde63da3..4978e24949c 100644 --- a/app/assets/javascripts/extensions/jquery.js +++ b/app/assets/javascripts/extensions/jquery.js @@ -1,3 +1,4 @@ +// Disable an element and add the 'disabled' Bootstrap class (function() { $.fn.extend({ disable: function() { @@ -5,6 +6,7 @@ } }); + // Enable an element and remove the 'disabled' Bootstrap class $.fn.extend({ enable: function() { return $(this).removeAttr('disabled').removeClass('disabled'); diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 09b5eb398d4..3fb3b1a8b51 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -33,18 +33,19 @@ this.render = bind(this.render, this); this.VIEW_TYPE = $('input#view[type=hidden]').val(); debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION); - $(document).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); + $(this.filesContainerElement).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); } FilesCommentButton.prototype.render = function(e) { var $currentTarget, buttonParentElement, lineContentElement, textFileElement; $currentTarget = $(e.currentTarget); + buttonParentElement = this.getButtonParent($currentTarget); - if (!this.shouldRender(e, buttonParentElement)) { - return; - } - textFileElement = this.getTextFileElement($currentTarget); + if (!this.validateButtonParent(buttonParentElement)) return; lineContentElement = this.getLineContent($currentTarget); + if (!this.validateLineContent(lineContentElement)) return; + + textFileElement = this.getTextFileElement($currentTarget); buttonParentElement.append(this.buildButton({ noteableType: textFileElement.attr('data-noteable-type'), noteableID: textFileElement.attr('data-noteable-id'), @@ -119,10 +120,14 @@ return newButtonParent.is(this.getButtonParent($(e.currentTarget))); }; - FilesCommentButton.prototype.shouldRender = function(e, buttonParentElement) { + FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) { return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS) && $(COMMENT_BUTTON_CLASS, buttonParentElement).length === 0; }; + FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { + return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== ''; + }; + return FilesCommentButton; })(); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js.es6 index 2e5b15f4b77..845313b6b38 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -1,3 +1,4 @@ +// Creates the variables for setting up GFM auto-completion (function() { if (window.GitLab == null) { window.GitLab = {}; @@ -8,18 +9,22 @@ dataLoaded: false, cachedData: {}, dataSource: '', + // Emoji Emoji: { template: '<li>${name} <img alt="${name}" height="20" src="${path}" width="20" /></li>' }, + // Team Members Members: { template: '<li>${username} <small>${title}</small></li>' }, Labels: { template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' }, + // Issues and MergeRequests Issues: { template: '<li><small>${id}</small> ${title}</li>' }, + // Milestones Milestones: { template: '<li>${title}</li>' }, @@ -47,30 +52,29 @@ } } }, - setup: function(input) { + setup: _.debounce(function(input) { + // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + // destroy previous instances this.destroyAtWho(); + // set up instances this.setupAtWho(); - if (this.dataSource) { - if (!this.dataLoading && !this.cachedData) { - this.dataLoading = true; - setTimeout((function(_this) { - return function() { - var fetch; - fetch = _this.fetchData(_this.dataSource); - return fetch.done(function(data) { - _this.dataLoading = false; - return _this.loadData(data); - }); - }; - })(this), 1000); - } - if (this.cachedData != null) { - return this.loadData(this.cachedData); - } + + if (this.dataSource && !this.dataLoading && !this.cachedData) { + this.dataLoading = true; + return this.fetchData(this.dataSource) + .done((data) => { + this.dataLoading = false; + this.loadData(data); + }); + }; + + if (this.cachedData != null) { + return this.loadData(this.cachedData); } - }, + }, 1000), setupAtWho: function() { + // Emoji this.input.atwho({ at: ':', displayTpl: (function(_this) { @@ -90,6 +94,7 @@ beforeInsert: this.DefaultOptions.beforeInsert } }); + // Team Members this.input.atwho({ at: '@', displayTpl: (function(_this) { @@ -223,7 +228,7 @@ } } }); - return this.input.atwho({ + this.input.atwho({ at: '~', alias: 'labels', searchKey: 'search', @@ -249,6 +254,68 @@ } } }); + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + this.input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + displayTpl: function(value) { + var tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }, + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; }, destroyAtWho: function() { return this.input.atwho('destroy'); @@ -259,12 +326,22 @@ loadData: function(data) { this.cachedData = data; this.dataLoaded = true; + // load members this.input.atwho('load', '@', data.members); + // load issues this.input.atwho('load', 'issues', data.issues); + // load milestones this.input.atwho('load', 'milestones', data.milestones); + // load merge requests this.input.atwho('load', 'mergerequests', data.mergerequests); + // load emojis this.input.atwho('load', ':', data.emojis); + // load labels this.input.atwho('load', '~', data.labels); + // load commands + this.input.atwho('load', '/', data.commands); + // This trigger at.js again + // otherwise we would be stuck with loading until the user types return $(':focus').trigger('keyup'); } }; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index c5d92831fbe..e034ca68645 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -21,45 +21,52 @@ $clearButton = $inputContainer.find('.js-dropdown-input-clear'); this.indeterminateIds = []; $clearButton.on('click', (function(_this) { + // Clear click return function(e) { e.preventDefault(); e.stopPropagation(); return _this.input.val('').trigger('keyup').focus(); }; })(this)); + // Key events timeout = ""; - this.input.on("keyup", (function(_this) { - return function(e) { + this.input + .on('keydown', function (e) { + var keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault() + } + }) + .on('keyup', function(e) { var keyCode; keyCode = e.which; if (ARROW_KEY_CODES.indexOf(keyCode) >= 0) { return; } - if (_this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (_this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { $inputContainer.removeClass(HAS_VALUE_CLASS); } - if (keyCode === 13) { + if (keyCode === 13 && !options.elIsInput) { return false; } - if (_this.options.remote) { + // Only filter asynchronously only if option remote is set + if (this.options.remote) { clearTimeout(timeout); return timeout = setTimeout(function() { - var blur_field; - blur_field = _this.shouldBlur(keyCode); - if (blur_field && _this.filterInputBlur) { - _this.input.blur(); + var blurField = this.shouldBlur(keyCode); + if (blurField && this.filterInputBlur) { + this.input.blur(); } - return _this.options.query(_this.input.val(), function(data) { - return _this.options.callback(data); - }); - }, 250); + return this.options.query(this.input.val(), function(data) { + return this.options.callback(data); + }.bind(this)); + }.bind(this), 250); } else { - return _this.filter(_this.input.val()); + return this.filter(this.input.val()); } - }; - })(this)); + }.bind(this)); } GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { @@ -75,11 +82,27 @@ if ((data != null) && !this.options.filterByText) { results = data; if (search_text !== '') { + // When data is an array of objects therefore [object Array] e.g. + // [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ] if (_.isArray(data)) { results = fuzzaldrinPlus.filter(data, search_text, { key: this.options.keys }); } else { + // If data is grouped therefore an [object Object]. e.g. + // { + // groupName1: [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ], + // groupName2: [ + // { prop: 'abc' }, + // { prop: 'def' } + // ] + // } if (gl.utils.isObject(data)) { results = {}; for (key in data) { @@ -106,14 +129,14 @@ matches = fuzzaldrinPlus.match($el.text().trim(), search_text); if (!$el.is('.dropdown-header')) { if (matches.length) { - return $el.show(); + return $el.show().removeClass('option-hidden'); } else { - return $el.hide(); + return $el.hide().addClass('option-hidden'); } } }); } else { - return elements.show(); + return elements.show().removeClass('option-hidden'); } } }; @@ -136,6 +159,7 @@ this.options.beforeSend(); } return this.dataEndpoint("", (function(_this) { + // Fetch the data by calling the data funcfion return function(data) { if (_this.options.success) { _this.options.success(data); @@ -167,6 +191,7 @@ }; })(this) }); + // Fetch the data through ajax if the data is a string }; return GitLabDropdownRemote; @@ -174,7 +199,7 @@ })(); GitLabDropdown = (function() { - var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, currentIndex; + var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, currentIndex; LOADING_CLASS = "is-loading"; @@ -186,6 +211,12 @@ currentIndex = -1; + NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'; + + SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)"; + + CURSOR_SELECT_SCROLL_PADDING = 5 + FILTER_INPUT = '.dropdown-input .dropdown-input-field'; function GitLabDropdown(el1, options) { @@ -199,15 +230,21 @@ self = this; selector = $(this.el).data("target"); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); + // Set Defaults ref = this.options, this.filterInput = (ref1 = ref.filterInput) != null ? ref1 : this.getElement(FILTER_INPUT), this.highlight = (ref2 = ref.highlight) != null ? ref2 : false, this.filterInputBlur = (ref3 = ref.filterInputBlur) != null ? ref3 : true; + // If no input is passed create a default one self = this; + // If selector was passed if (_.isString(this.filterInput)) { this.filterInput = this.getElement(this.filterInput); } searchFields = this.options.search ? this.options.search.fields : []; if (this.options.data) { + // If we provided data + // data could be an array of objects or a group of arrays if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { this.fullData = this.options.data; + currentIndex = -1; this.parseData(this.options.data); } else { this.remote = new GitLabDropdownRemote(this.options.data, { @@ -221,12 +258,15 @@ return _this.filter.input.trigger('keyup'); } }; + // Remote data })(this) }); } } + // Init filterable if (this.options.filterable) { this.filter = new GitLabDropdownFilter(this.filterInput, { + elIsInput: $(this.el).is('input'), filterInputBlur: this.filterInputBlur, filterByText: this.options.filterByText, onFilter: this.options.onFilter, @@ -235,7 +275,7 @@ keys: searchFields, elements: (function(_this) { return function() { - selector = '.dropdown-content li:not(.divider)'; + selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; if (_this.dropdown.find('.dropdown-toggle-page').length) { selector = ".dropdown-page-one " + selector; } @@ -251,23 +291,29 @@ return function(data) { _this.parseData(data); if (_this.filterInput.val() !== '') { - selector = '.dropdown-content li:not(.divider):visible'; + selector = SELECTABLE_CLASSES; if (_this.dropdown.find('.dropdown-toggle-page').length) { selector = ".dropdown-page-one " + selector; } - $(selector, _this.dropdown).first().find('a').addClass('is-focused'); - return currentIndex = 0; + if ($(_this.el).is('input')) { + currentIndex = -1; + } else { + $(selector, _this.dropdown).first().find('a').addClass('is-focused'); + currentIndex = 0; + } } }; })(this) }); } + // Event listeners this.dropdown.on("shown.bs.dropdown", this.opened); this.dropdown.on("hidden.bs.dropdown", this.hidden); $(this.el).on("update.label", this.updateLabel); this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate); this.dropdown.on('keyup', (function(_this) { return function(e) { + // Escape key if (e.which === 27) { return $('.dropdown-menu-close', _this.dropdown).trigger('click'); } @@ -306,11 +352,18 @@ if (self.options.clicked) { self.options.clicked(selected, $el, e); } - return $el.trigger('blur'); + + // Update label right after all modifications in dropdown has been done + if (self.options.toggleLabel) { + self.updateLabel(selected, $el, self); + } + + $el.trigger('blur'); }); } } + // Finds an element inside wrapper element GitLabDropdown.prototype.getElement = function(selector) { return this.dropdown.find(selector); }; @@ -328,6 +381,7 @@ } } menu.toggleClass(PAGE_TWO_CLASS); + // Focus first visible input on active page return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); }; @@ -335,23 +389,28 @@ var full_html, groupData, html, name; this.renderedData = data; if (this.options.filterable && data.length === 0) { + // render no matching results html = [this.noResults()]; } else { + // Handle array groups if (gl.utils.isObject(data)) { html = []; for (name in data) { groupData = data[name]; html.push(this.renderItem({ header: name + // Add header for each group }, name)); this.renderData(groupData, name).map(function(item) { return html.push(item); }); } } else { + // Render each row html = this.renderData(data); } } + // Render the full menu full_html = this.renderMenu(html); return this.appendMenu(full_html); }; @@ -371,7 +430,7 @@ var $target; if (this.options.multiSelect) { $target = $(e.target); - if (!$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) { + if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) { e.stopPropagation(); return false; } else { @@ -382,13 +441,16 @@ GitLabDropdown.prototype.opened = function() { var contentHtml; + this.resetRows(); this.addArrowKeyEvent(); + if (this.options.setIndeterminateIds) { this.options.setIndeterminateIds.call(this); } if (this.options.setActiveIds) { this.options.setActiveIds.call(this); } + // Makes indeterminate items effective if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { this.parseData(this.fullData); } @@ -399,16 +461,31 @@ if (this.options.filterable) { this.filterInput.focus(); } + + if (this.options.showMenuAbove) { + this.positionMenuAbove(); + } + return this.dropdown.trigger('shown.gl.dropdown'); }; + GitLabDropdown.prototype.positionMenuAbove = function() { + var $button = $(this.el); + var $menu = this.dropdown.find('.dropdown-menu'); + + $menu.css('top', ($button.height() + $menu.height()) * -1); + }; + GitLabDropdown.prototype.hidden = function(e) { var $input; + this.resetRows(); this.removeArrayKeyEvent(); $input = this.dropdown.find(".dropdown-input-field"); if (this.options.filterable) { $input.blur().val(""); } + // Triggering 'keyup' will re-render the dropdown which is not always required + // specially if we want to keep the state of the dropdown needed for bulk-assignment if (!this.options.persistWhenHide) { $input.trigger("keyup"); } @@ -421,6 +498,7 @@ return this.dropdown.trigger('hidden.gl.dropdown'); }; + // Render the full menu GitLabDropdown.prototype.renderMenu = function(html) { var menu_html; menu_html = ""; @@ -432,6 +510,7 @@ return menu_html; }; + // Append the menu into the dropdown GitLabDropdown.prototype.appendMenu = function(html) { var selector; selector = '.dropdown-content'; @@ -447,34 +526,42 @@ group = false; } if (index == null) { + // Render the row index = false; } html = ""; + // Divider if (data === "divider") { return "<li class='divider'></li>"; } + // Separator is a full-width divider if (data === "separator") { return "<li class='separator'></li>"; } + // Header if (data.header != null) { - return "<li class='dropdown-header'>" + data.header + "</li>"; + return _.template('<li class="dropdown-header"><%- header %></li>')({ header: data.header }); } if (this.options.renderRow) { + // Call the render function html = this.options.renderRow.call(this.options, data, this); } else { if (!selected) { value = this.options.id ? this.options.id(data) : data.id; fieldName = this.options.fieldName; + field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); if (field.length) { selected = true; } } + // Set URL if (this.options.url != null) { url = this.options.url(data); } else { url = data.url != null ? data.url : '#'; } + // Set Text if (this.options.text != null) { text = this.options.text(data); } else { @@ -488,11 +575,16 @@ text = this.highlightTextMatches(text, this.filterInput.val()); } if (group) { - groupAttrs = "data-group='" + group + "' data-index='" + index + "'"; + groupAttrs = 'data-group=' + group + ' data-index=' + index; } else { groupAttrs = ''; } - html = "<li> <a href='" + url + "' " + groupAttrs + " class='" + cssClass + "'> " + text + " </a> </li>"; + html = _.template('<li><a href="<%- url %>" <%- groupAttrs %> class="<%- cssClass %>"><%= text %></a></li>')({ + url: url, + groupAttrs: groupAttrs, + cssClass: cssClass, + text: text + }); } return html; }; @@ -514,17 +606,6 @@ return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>"; }; - GitLabDropdown.prototype.highlightRow = function(index) { - var selector; - if (this.filterInput.val() !== "") { - selector = '.dropdown-content li:first-child a'; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one .dropdown-content li:first-child a"; - } - return this.getElement(selector).addClass('is-focused'); - } - }; - GitLabDropdown.prototype.rowClicked = function(el) { var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value; fieldName = this.options.fieldName; @@ -539,34 +620,31 @@ selectedObject = this.renderedData[selectedIndex]; } } + field = []; value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; if (isInput) { field = $(this.el); - } else { - field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); + } else if(value) { + field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']"); } if (el.hasClass(ACTIVE_CLASS)) { el.removeClass(ACTIVE_CLASS); - if (isInput) { - field.val(''); - } else { - field.remove(); - } - if (this.options.toggleLabel) { - return this.updateLabel(selectedObject, el, this); - } else { - return selectedObject; + if (field && field.length) { + if (isInput) { + field.val(''); + } else { + field.remove(); + } } } else if (el.hasClass(INDETERMINATE_CLASS)) { el.addClass(ACTIVE_CLASS); el.removeClass(INDETERMINATE_CLASS); - if (value == null) { + if (field && field.length && value == null) { field.remove(); } - if (!field.length && fieldName) { - this.addInput(fieldName, value); + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); } - return selectedObject; } else { if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); @@ -574,26 +652,26 @@ this.dropdown.parent().find("input[name='" + fieldName + "']").remove(); } } - if (value == null) { + if (field && field.length && value == null) { field.remove(); } + // Toggle active class for the tick mark el.addClass(ACTIVE_CLASS); - if (this.options.toggleLabel) { - this.updateLabel(selectedObject, el, this); - } if (value != null) { - if (!field.length && fieldName) { - this.addInput(fieldName, value); - } else { + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } else if (field && field.length) { field.val(value).trigger('change'); } } - return selectedObject; } + + return selectedObject; }; - GitLabDropdown.prototype.addInput = function(fieldName, value) { + GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) { var $input; + // Create hidden input for form $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value); if (this.options.inputId != null) { $input.attr('id', this.options.inputId); @@ -601,17 +679,26 @@ return this.dropdown.before($input); }; - GitLabDropdown.prototype.selectRowAtIndex = function(e, index) { + GitLabDropdown.prototype.selectRowAtIndex = function(index) { var $el, selector; - selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a"; + // If we pass an option index + if (typeof index !== "undefined") { + selector = SELECTABLE_CLASSES + ":eq(" + index + ") a"; + } else { + selector = ".dropdown-content .is-focused"; + } if (this.dropdown.find(".dropdown-toggle-page").length) { selector = ".dropdown-page-one " + selector; } + // simulate a click on the first link $el = $(selector, this.dropdown); if ($el.length) { - e.preventDefault(); - e.stopImmediatePropagation(); - return $el.first().trigger('click'); + var href = $el.attr('href'); + if (href && href !== '#') { + Turbolinks.visit(href); + } else { + $el.first().trigger('click'); + } } }; @@ -619,7 +706,7 @@ var $input, ARROW_KEY_CODES, selector; ARROW_KEY_CODES = [38, 40]; $input = this.dropdown.find(".dropdown-input-field"); - selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator)'; + selector = SELECTABLE_CLASSES; if (this.dropdown.find(".dropdown-toggle-page").length) { selector = ".dropdown-page-one " + selector; } @@ -632,11 +719,15 @@ e.stopImmediatePropagation(); PREV_INDEX = currentIndex; $listItems = $(selector, _this.dropdown); + // if @options.filterable + // $input.blur() if (currentKeyCode === 40) { + // Move down if (currentIndex < ($listItems.length - 1)) { currentIndex += 1; } } else if (currentKeyCode === 38) { + // Move up if (currentIndex > 0) { currentIndex -= 1; } @@ -647,7 +738,8 @@ return false; } if (currentKeyCode === 13 && currentIndex !== -1) { - return _this.selectRowAtIndex(e, currentIndex); + e.preventDefault(); + _this.selectRowAtIndex(); } }; })(this)); @@ -657,23 +749,40 @@ return $('body').off('keydown'); }; + GitLabDropdown.prototype.resetRows = function resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); + }; + GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop; + // Remove the class for the previously focused row $('.is-focused', this.dropdown).removeClass('is-focused'); + // Update the class for the row at the specific index $listItem = $listItems.eq(index); $listItem.find('a:first-child').addClass("is-focused"); + // Dropdown content scroll area $dropdownContent = $listItem.closest('.dropdown-content'); dropdownScrollTop = $dropdownContent.scrollTop(); dropdownContentHeight = $dropdownContent.outerHeight(); dropdownContentTop = $dropdownContent.prop('offsetTop'); dropdownContentBottom = dropdownContentTop + dropdownContentHeight; + // Get the offset bottom of the list item listItemHeight = $listItem.outerHeight(); listItemTop = $listItem.prop('offsetTop'); listItemBottom = listItemTop + listItemHeight; - if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { - return $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom); - } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { - return $dropdownContent.scrollTop(listItemTop - dropdownContentTop); + if (!index) { + // Scroll the dropdown content to the top + $dropdownContent.scrollTop(0) + } else if (index === ($listItems.length - 1)) { + // Scroll the dropdown content to the bottom + $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); + } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) { + // Scroll the dropdown content down + $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING); + } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) { + // Scroll the dropdown content up + return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING); } }; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 528a673eb15..2703adc0705 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,12 +3,15 @@ function GLForm(form) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); + // Before we start, we should clean up any previous data for this form this.destroy(); + // Setup the form this.setupForm(); this.form.data('gl-form', this); } GLForm.prototype.destroy = function() { + // Clean form listeners this.clearEventListeners(); return this.form.data('gl-form', null); }; @@ -21,12 +24,15 @@ this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); + // remove notify commit author checkbox for non-commit notes GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); + // form and textarea event listeners this.addEventListeners(); gl.text.init(this.form); } + // hide discard button this.form.find('.js-note-discard').hide(); return this.form.show(); }; diff --git a/app/assets/javascripts/graphs/graphs_bundle.js b/app/assets/javascripts/graphs/graphs_bundle.js index b95faadc8e7..4886da9f21f 100644 --- a/app/assets/javascripts/graphs/graphs_bundle.js +++ b/app/assets/javascripts/graphs/graphs_bundle.js @@ -1,7 +1,11 @@ - +// This is a manifest file that'll be compiled into including all the files listed below. +// Add new JavaScript/Coffee 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 . */ (function() { - }).call(this); diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index a646ca1d84f..7d9d4d7c679 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -204,6 +204,7 @@ function ContributorsAuthorGraph(data1) { this.data = data1; + // Don't split graph size in half for mobile devices. if ($(window).width() < 768) { this.width = $('.content').width() - 80; } else { diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index fd5b6dc0ddd..5f06186504b 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -5,14 +5,15 @@ function GroupsSelect() { $('.ajax-groups-select').each((function(_this) { return function(i, select) { - var skip_ldap; + var skip_ldap, skip_groups; skip_ldap = $(select).hasClass('skip_ldap'); + skip_groups = $(select).data('skip-groups') || []; return $(select).select2({ placeholder: "Search for a group", multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query: function(query) { - return Api.groups(query.term, skip_ldap, function(groups) { + return Api.groups(query.term, skip_ldap, skip_groups, function(groups) { var data; data = { results: groups @@ -38,6 +39,7 @@ return _this.formatSelection.apply(_this, args); }, dropdownCssClass: "ajax-groups-dropdown", + // we do not want to escape markup since we are displaying html in results escapeMarkup: function(m) { return m; } diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 0f840821f53..4aced1e618f 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -10,21 +10,24 @@ ImporterStatus.prototype.initStatusPage = function() { $('.js-add-to-import').off('click').on('click', (function(_this) { return function(e) { - var $btn, $namespace_input, $target_field, $tr, id, new_namespace; + var $btn, $namespace_input, $target_field, $tr, id, target_namespace, newName; $btn = $(e.currentTarget); $tr = $btn.closest('tr'); $target_field = $tr.find('.import-target'); - $namespace_input = $target_field.find('input'); + $namespace_input = $target_field.find('.js-select-namespace option:selected'); id = $tr.attr('id').replace('repo_', ''); - new_namespace = null; + target_namespace = null; + newName = null; if ($namespace_input.length > 0) { - new_namespace = $namespace_input.prop('value'); - $target_field.empty().append(new_namespace + "/" + ($target_field.data('project_name'))); + target_namespace = $namespace_input[0].innerHTML; + newName = $target_field.find('#path').prop('value'); + $target_field.empty().append(target_namespace + "/" + newName); } $btn.disable().addClass('is-loading'); return $.post(_this.import_url, { repo_id: id, - new_namespace: new_namespace + target_namespace: target_namespace, + new_name: newName }, { dataType: 'script' }); @@ -70,7 +73,7 @@ if ($('.js-importer-status').length) { var jobsImportPath = $('.js-importer-status').data('jobs-import-path'); var importPath = $('.js-importer-status').data('import-path'); - + new ImporterStatus(jobsImportPath, importPath); } }); diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js.es6 index f27f1bad1f7..57f7e4ef230 100644 --- a/app/assets/javascripts/issuable.js +++ b/app/assets/javascripts/issuable.js.es6 @@ -5,46 +5,52 @@ this.Issuable = { init: function() { - if (!issuable_created) { - issuable_created = true; - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - return Issuable.initLabelFilterRemove(); - } + Issuable.initTemplates(); + Issuable.initSearch(); + Issuable.initChecks(); + Issuable.initResetFilters(); + return Issuable.initLabelFilterRemove(); }, initTemplates: function() { return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); }, initSearch: function() { - this.timer = null; - return $('#issue_search').off('keyup').on('keyup', function() { - clearTimeout(this.timer); - return this.timer = setTimeout(function() { - var $form, $input, $search; - $search = $('#issue_search'); - $form = $('.js-filter-form'); - $input = $("input[name='" + ($search.attr('name')) + "']", $form); - if ($input.length === 0) { - $form.append("<input type='hidden' name='" + ($search.attr('name')) + "' value='" + (_.escape($search.val())) + "'/>"); - } else { - $input.val($search.val()); - } - if ($search.val() !== '') { - return Issuable.filterResults($form); - } - }, 500); + // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing + const debouncedExecSearch = _.debounce(Issuable.executeSearch, 500, false); + + $('#issuable_search').off('keyup').on('keyup', debouncedExecSearch); + + // ensures existing filters are preserved when manually submitted + $('#issue_search_form').on('submit', (e) => { + e.preventDefault(); + debouncedExecSearch(e); }); }, + executeSearch: function(e) { + const $search = $('#issuable_search'); + const $searchName = $search.attr('name'); + const $searchValue = $search.val(); + const $filtersForm = $('.js-filter-form'); + const $input = $(`input[name='${$searchName}']`, $filtersForm); + + if (!$input.length) { + $filtersForm.append(`<input type='hidden' name='${$searchName}' value='${_.escape($searchValue)}'/>`); + } else { + $input.val($searchValue); + } + + Issuable.filterResults($filtersForm); + }, initLabelFilterRemove: function() { return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { var $button; $button = $(this); + // Remove the label input box $('input[name="label_name[]"]').filter(function() { return this.value === $button.data('label'); }).remove(); + // Submit the form to get new data Issuable.filterResults($('.filter-form')); - return $('.js-label-select').trigger('update.label'); }); }, filterResults: (function(_this) { @@ -58,6 +64,17 @@ return Turbolinks.visit(issuesUrl); }; })(this), + initResetFilters: function() { + $('.reset-filters').on('click', function(e) { + e.preventDefault(); + const target = e.target; + const $form = $(target).parents('.js-filter-form'); + const baseIssuesUrl = target.href; + + $form.attr('action', baseIssuesUrl); + Turbolinks.visit(baseIssuesUrl); + }); + }, initChecks: function() { this.issuableBulkActions = $('.bulk-update').data('bulkActions'); $('.check_all_issues').off('click').on('click', function() { @@ -67,19 +84,22 @@ return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); }, checkChanged: function() { - var checked_issues, ids; - checked_issues = $('.selected_issue:checked'); - if (checked_issues.length > 0) { - ids = $.map(checked_issues, function(value) { + const $checkedIssues = $('.selected_issue:checked'); + const $updateIssuesIds = $('#update_issuable_ids'); + const $issuesOtherFilters = $('.issues-other-filters'); + const $issuesBulkUpdate = $('.issues_bulk_update'); + + if ($checkedIssues.length > 0) { + let ids = $.map($checkedIssues, function(value) { return $(value).data('id'); }); - $('#update_issues_ids').val(ids); - $('.issues-other-filters').hide(); - $('.issues_bulk_update').show(); + $updateIssuesIds.val(ids); + $issuesOtherFilters.hide(); + $issuesBulkUpdate.show(); } else { - $('#update_issues_ids').val([]); - $('.issues_bulk_update').hide(); - $('.issues-other-filters').show(); + $updateIssuesIds.val([]); + $issuesBulkUpdate.hide(); + $issuesOtherFilters.show(); this.issuableBulkActions.willUpdateLabels = false; } return true; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 297d4f029f0..b7f92ae9883 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -102,20 +102,34 @@ }; IssuableForm.prototype.initMoveDropdown = function() { - var $moveDropdown; + var $moveDropdown, pageSize; $moveDropdown = $('.js-move-dropdown'); if ($moveDropdown.length) { + pageSize = $moveDropdown.data('page-size'); return $('.js-move-dropdown').select2({ ajax: { url: $moveDropdown.data('projects-url'), - results: function(data) { + quietMillis: 125, + data: function(term, page, context) { return { - results: data + search: term, + offset_id: context }; }, - data: function(query) { + results: function(data) { + var context, + more; + + if (data.length >= pageSize) + more = true; + + if (data[data.length - 1]) + context = data[data.length - 1].id; + return { - search: query + results: data, + more: more, + context: context }; } }, diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 6838d9d8da1..261bf6137c2 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,10 +1,6 @@ /*= require flash */ - - /*= require jquery.waitforimages */ - - /*= require task_list */ (function() { @@ -13,6 +9,7 @@ this.Issue = (function() { function Issue() { this.submitNoteForm = bind(this.submitNoteForm, this); + // Prevent duplicate event bindings this.disableTaskList(); if ($('a.btn-close').length) { this.initTaskList(); @@ -99,6 +96,8 @@ url: $('form.js-issuable-update').attr('action'), data: patchData }); + // TODO (rspeicher): Make the issue description inline-editable like a note so + // that we can re-use its form here }; Issue.prototype.initMergeRequests = function() { @@ -127,7 +126,9 @@ Issue.prototype.initCanCreateBranch = function() { var $container; - $container = $('div#new-branch'); + $container = $('#new-branch'); + // If the user doesn't have the required permissions the container isn't + // rendered at all. if ($container.length === 0) { return; } @@ -139,7 +140,6 @@ if (data.can_create_branch) { $container.find('.checking').hide(); $container.find('.available').show(); - return $container.find('a').attr('disabled', false); } else { $container.find('.checking').hide(); return $container.find('.unavailable').show(); diff --git a/app/assets/javascripts/issues-bulk-assignment.js b/app/assets/javascripts/issues-bulk-assignment.js deleted file mode 100644 index 98d3358ba92..00000000000 --- a/app/assets/javascripts/issues-bulk-assignment.js +++ /dev/null @@ -1,161 +0,0 @@ -(function() { - this.IssuableBulkActions = (function() { - function IssuableBulkActions(opts) { - var ref, ref1, ref2; - if (opts == null) { - opts = {}; - } - this.container = (ref = opts.container) != null ? ref : $('.content'), this.form = (ref1 = opts.form) != null ? ref1 : this.getElement('.bulk-update'), this.issues = (ref2 = opts.issues) != null ? ref2 : this.getElement('.issues-list .issue'); - this.form.data('bulkActions', this); - this.willUpdateLabels = false; - this.bindEvents(); - Issuable.initChecks(); - } - - IssuableBulkActions.prototype.getElement = function(selector) { - return this.container.find(selector); - }; - - IssuableBulkActions.prototype.bindEvents = function() { - return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); - }; - - IssuableBulkActions.prototype.onFormSubmit = function(e) { - e.preventDefault(); - return this.submit(); - }; - - IssuableBulkActions.prototype.submit = function() { - var _this, xhr; - _this = this; - xhr = $.ajax({ - url: this.form.attr('action'), - method: this.form.attr('method'), - dataType: 'JSON', - data: this.getFormDataAsObject() - }); - xhr.done(function(response, status, xhr) { - return location.reload(); - }); - xhr.fail(function() { - return new Flash("Issue update failed"); - }); - return xhr.always(this.onFormSubmitAlways.bind(this)); - }; - - IssuableBulkActions.prototype.onFormSubmitAlways = function() { - return this.form.find('[type="submit"]').enable(); - }; - - IssuableBulkActions.prototype.getSelectedIssues = function() { - return this.issues.has('.selected_issue:checked'); - }; - - IssuableBulkActions.prototype.getLabelsFromSelection = function() { - var labels; - labels = []; - this.getSelectedIssues().map(function() { - var _labels; - _labels = $(this).data('labels'); - if (_labels) { - return _labels.map(function(labelId) { - if (labels.indexOf(labelId) === -1) { - return labels.push(labelId); - } - }); - } - }); - return labels; - }; - - - /** - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - */ - - IssuableBulkActions.prototype.getUnmarkedIndeterminedLabels = function() { - var el, i, id, j, labelsToKeep, len, len1, ref, ref1, result; - result = []; - labelsToKeep = []; - ref = this.getElement('.labels-filter .is-indeterminate'); - for (i = 0, len = ref.length; i < len; i++) { - el = ref[i]; - labelsToKeep.push($(el).data('labelId')); - } - ref1 = this.getLabelsFromSelection(); - for (j = 0, len1 = ref1.length; j < len1; j++) { - id = ref1[j]; - if (labelsToKeep.indexOf(id) === -1) { - result.push(id); - } - } - return result; - }; - - - /** - * Simple form serialization, it will return just what we need - * Returns key/value pairs from form data - */ - - IssuableBulkActions.prototype.getFormDataAsObject = function() { - var formData; - formData = { - update: { - state_event: this.form.find('input[name="update[state_event]"]').val(), - assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), - milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), - issues_ids: this.form.find('input[name="update[issues_ids]"]').val(), - subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), - add_label_ids: [], - remove_label_ids: [] - } - }; - if (this.willUpdateLabels) { - this.getLabelsToApply().map(function(id) { - return formData.update.add_label_ids.push(id); - }); - this.getLabelsToRemove().map(function(id) { - return formData.update.remove_label_ids.push(id); - }); - } - return formData; - }; - - IssuableBulkActions.prototype.getLabelsToApply = function() { - var $labels, labelIds; - labelIds = []; - $labels = this.form.find('.labels-filter input[name="update[label_ids][]"]'); - $labels.each(function(k, label) { - if (label) { - return labelIds.push(parseInt($(label).val())); - } - }); - return labelIds; - }; - - - /** - * Returns Label IDs that will be removed from issue selection - * @return {Array} Array of labels IDs - */ - - IssuableBulkActions.prototype.getLabelsToRemove = function() { - var indeterminatedLabels, labelsToApply, result; - result = []; - indeterminatedLabels = this.getUnmarkedIndeterminedLabels(); - labelsToApply = this.getLabelsToApply(); - indeterminatedLabels.map(function(id) { - if (labelsToApply.indexOf(id) === -1) { - return result.push(id); - } - }); - return result; - }; - - return IssuableBulkActions; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/issues_bulk_assignment.js.es6 b/app/assets/javascripts/issues_bulk_assignment.js.es6 new file mode 100644 index 00000000000..0808f538f01 --- /dev/null +++ b/app/assets/javascripts/issues_bulk_assignment.js.es6 @@ -0,0 +1,149 @@ +((global) => { + + class IssuableBulkActions { + constructor({ container, form, issues } = {}) { + this.container = container || $('.content'), + this.form = form || this.getElement('.bulk-update'); + this.issues = issues || this.getElement('.issues-list .issue'); + this.form.data('bulkActions', this); + this.willUpdateLabels = false; + this.bindEvents(); + // Fixes bulk-assign not working when navigating through pages + Issuable.initChecks(); + } + + getElement(selector) { + return this.container.find(selector); + } + + bindEvents() { + return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); + } + + onFormSubmit(e) { + e.preventDefault(); + return this.submit(); + } + + submit() { + const _this = this; + const xhr = $.ajax({ + url: this.form.attr('action'), + method: this.form.attr('method'), + dataType: 'JSON', + data: this.getFormDataAsObject() + }); + xhr.done(() => window.location.reload()); + xhr.fail(() => new Flash("Issue update failed")); + return xhr.always(this.onFormSubmitAlways.bind(this)); + } + + onFormSubmitAlways() { + return this.form.find('[type="submit"]').enable(); + } + + getSelectedIssues() { + return this.issues.has('.selected_issue:checked'); + } + + getLabelsFromSelection() { + const labels = []; + this.getSelectedIssues().map(function() { + const labelsData = $(this).data('labels'); + if (labelsData) { + return labelsData.map(function(labelId) { + if (labels.indexOf(labelId) === -1) { + return labels.push(labelId); + } + }); + } + }); + return labels; + } + + + /** + * Will return only labels that were marked previously and the user has unmarked + * @return {Array} Label IDs + */ + + getUnmarkedIndeterminedLabels() { + const result = []; + const labelsToKeep = []; + + this.getElement('.labels-filter .is-indeterminate') + .each((i, el) => labelsToKeep.push($(el).data('labelId'))); + + this.getLabelsFromSelection().forEach((id) => { + if (labelsToKeep.indexOf(id) === -1) { + result.push(id); + } + }); + + return result; + } + + + /** + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + */ + + getFormDataAsObject() { + const formData = { + update: { + state_event: this.form.find('input[name="update[state_event]"]').val(), + assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), + issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), + subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + add_label_ids: [], + remove_label_ids: [] + } + }; + if (this.willUpdateLabels) { + this.getLabelsToApply().map(function(id) { + return formData.update.add_label_ids.push(id); + }); + this.getLabelsToRemove().map(function(id) { + return formData.update.remove_label_ids.push(id); + }); + } + return formData; + } + + getLabelsToApply() { + const labelIds = []; + const $labels = this.form.find('.labels-filter input[name="update[label_ids][]"]'); + $labels.each(function(k, label) { + if (label) { + return labelIds.push(parseInt($(label).val())); + } + }); + return labelIds; + } + + + /** + * Returns Label IDs that will be removed from issue selection + * @return {Array} Array of labels IDs + */ + + getLabelsToRemove() { + const result = []; + const indeterminatedLabels = this.getUnmarkedIndeterminedLabels(); + const labelsToApply = this.getLabelsToApply(); + indeterminatedLabels.map(function(id) { + // We need to exclude label IDs that will be applied + // By not doing this will cause issues from selection to not add labels at all + if (labelsToApply.indexOf(id) === -1) { + return result.push(id); + } + }); + return result; + } + } + + global.IssuableBulkActions = IssuableBulkActions; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/label_manager.js.es6 b/app/assets/javascripts/label_manager.js.es6 new file mode 100644 index 00000000000..bc68e53504f --- /dev/null +++ b/app/assets/javascripts/label_manager.js.es6 @@ -0,0 +1,106 @@ +((global) => { + + class LabelManager { + constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { + this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority'); + this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels'); + this.otherLabels = otherLabels || $('.js-other-labels'); + this.errorMessage = 'Unable to update label prioritization at this time'; + this.prioritizedLabels.sortable({ + items: 'li', + placeholder: 'list-placeholder', + axis: 'y', + update: this.onPrioritySortUpdate.bind(this) + }); + this.bindEvents(); + } + + bindEvents() { + return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); + } + + onTogglePriorityClick(e) { + e.preventDefault(); + const _this = e.data; + const $btn = $(e.currentTarget); + const $label = $(`#${$btn.data('domId')}`); + const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add'; + const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`); + $tooltip.tooltip('destroy'); + return _this.toggleLabelPriority($label, action); + } + + toggleLabelPriority($label, action, persistState) { + if (persistState == null) { + persistState = true; + } + let xhr; + const _this = this; + const url = $label.find('.js-toggle-priority').data('url'); + let $target = this.prioritizedLabels; + let $from = this.otherLabels; + if (action === 'remove') { + $target = this.otherLabels; + $from = this.prioritizedLabels; + } + if ($from.find('li').length === 1) { + $from.find('.empty-message').removeClass('hidden'); + } + if (!$target.find('li').length) { + $target.find('.empty-message').addClass('hidden'); + } + $label.detach().appendTo($target); + // Return if we are not persisting state + if (!persistState) { + return; + } + if (action === 'remove') { + xhr = $.ajax({ + url, + type: 'DELETE' + }); + // Restore empty message + if (!$from.find('li').length) { + $from.find('.empty-message').removeClass('hidden'); + } + } else { + xhr = this.savePrioritySort($label, action); + } + return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action)); + } + + onPrioritySortUpdate() { + const xhr = this.savePrioritySort(); + return xhr.fail(function() { + return new Flash(this.errorMessage, 'alert'); + }); + } + + savePrioritySort() { + return $.post({ + url: this.prioritizedLabels.data('url'), + data: { + label_ids: this.getSortedLabelsIds() + } + }); + } + + rollbackLabelPosition($label, originalAction) { + const action = originalAction === 'remove' ? 'add' : 'remove'; + this.toggleLabelPriority($label, action, false); + return new Flash(this.errorMessage, 'alert'); + } + + getSortedLabelsIds() { + const sortedIds = []; + this.prioritizedLabels.find('li').each(function() { + sortedIds.push($(this).data('id')); + }); + return sortedIds; + } + } + + gl.LabelManager = LabelManager; + +})(window.gl || (window.gl = {})); + diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js index fe071fca67c..cb16e2ba814 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels.js @@ -26,13 +26,16 @@ var previewColor; previewColor = $('input#label_color').val(); return $('div.label-color-preview').css('background-color', previewColor); + // Updates the the preview color with the hex-color input }; + // Updates the preview color with a click on a suggested color Labels.prototype.setSuggestedColor = function(e) { var color; color = $(e.currentTarget).data('color'); $('input#label_color').val(color); this.updateColorPreview(); + // Notify the form, that color has changed $('.label-form').trigger('keyup'); return e.preventDefault(); }; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 675dd5b7cea..f1e719937c7 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,31 +4,37 @@ var _this; _this = this; $('.js-label-select').each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo; + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove; $dropdown = $(dropdown); - projectId = $dropdown.data('project-id'); + $toggleText = $dropdown.find('.dropdown-toggle-text'); + namespacePath = $dropdown.data('namespace-path'); + projectPath = $dropdown.data('project-path'); labelUrl = $dropdown.data('labels'); issueUpdateURL = $dropdown.data('issueUpdate'); selectedLabel = $dropdown.data('selected'); if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - newLabelField = $('#new_label_name'); - newColorField = $('#new_label_color'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); + showMenuAbove = $dropdown.data('showMenuAbove'); defaultLabel = $dropdown.data('default-label'); abilityName = $dropdown.data('ability-name'); $selectbox = $dropdown.closest('.selectbox'); $block = $selectbox.closest('.block'); $form = $dropdown.closest('form'); $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); $value = $block.find('.value'); - $newLabelError = $('.js-label-error'); - $colorPreview = $('.js-dropdown-label-color-preview'); - $newLabelCreateButton = $('.js-new-label-btn'); - $newLabelError.hide(); $loading = $block.find('.block-loading').fadeOut(); + fieldName = $dropdown.data('field-name'); + useId = $dropdown.is('.js-issuable-form-dropdown, .js-filter-bulk-update, .js-label-sidebar-dropdown'); + propertyName = useId ? 'id' : 'title'; + initialSelected = $selectbox + .find('input[name="' + $dropdown.data('field-name') + '"]') + .map(function () { + return this.value; + }).get(); if (issueUpdateURL != null) { issueURLSplit = issueUpdateURL.split('/'); } @@ -36,65 +42,22 @@ labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); labelNoneHTMLTemplate = '<span class="no-value">None</span>'; } - if (newLabelField.length) { - $('.suggest-colors-dropdown a').on("click", function(e) { - e.preventDefault(); - e.stopPropagation(); - newColorField.val($(this).data('color')).trigger('change'); - return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active'); - }); - resetForm = function() { - newLabelField.val('').trigger('change'); - newColorField.val('').trigger('change'); - return $colorPreview.css('background-color', '').parent().removeClass('is-active'); - }; - $('.dropdown-menu-back').on('click', function() { - return resetForm(); - }); - $('.js-cancel-label-btn').on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - resetForm(); - return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); - }); - enableLabelCreateButton = function() { - if (newLabelField.val() !== '' && newColorField.val() !== '') { - $newLabelError.hide(); - return $newLabelCreateButton.enable(); - } else { - return $newLabelCreateButton.disable(); - } - }; - saveLabel = function() { - return Api.newLabel(projectId, { - name: newLabelField.val(), - color: newColorField.val() - }, function(label) { - var errors; - $newLabelCreateButton.enable(); - if (label.message != null) { - errors = _.map(label.message, function(value, key) { - return key + " " + value[0]; - }); - return $newLabelError.html(errors.join("<br/>")).show(); - } else { - return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); - } - }); - }; - newLabelField.on('keyup change', enableLabelCreateButton); - newColorField.on('keyup change', enableLabelCreateButton); - $newLabelCreateButton.disable().on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - return saveLabel(); - }); + + $sidebarLabelTooltip.tooltip(); + + if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { + new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), namespacePath, projectPath); } + saveLabelData = function() { var data, selected; - selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() { + selected = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "']").map(function() { return this.value; }).get(); + + if (_.isEqual(initialSelected, selected)) return; + initialSelected = selected; + data = {}; data[abilityName] = {}; data[abilityName].label_ids = selected; @@ -109,7 +72,7 @@ dataType: 'JSON', data: data }).done(function(data) { - var labelCount, template; + var labelCount, template, labelTooltipTitle, labelTitles; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); @@ -118,11 +81,34 @@ if (data.labels.length) { template = labelHTMLTemplate(data); labelCount = data.labels.length; - } else { + } + else { template = labelNoneHTMLTemplate; } $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); + + if (data.labels.length) { + labelTitles = data.labels.map(function(label) { + return label.title; + }); + + if (labelTitles.length > 5) { + labelTitles = labelTitles.slice(0, 5); + labelTitles.push('and ' + (data.labels.length - 5) + ' more'); + } + + labelTooltipTitle = labelTitles.join(', '); + } + else { + labelTooltipTitle = ''; + $sidebarLabelTooltip.tooltip('destroy'); + } + + $sidebarLabelTooltip + .attr('title', labelTooltipTitle) + .tooltip('fixTitle'); + $('.has-tooltip', $value).tooltip({ container: 'body' }); @@ -136,6 +122,7 @@ }); }; return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, data: function(term, callback) { return $.ajax({ url: labelUrl @@ -155,23 +142,29 @@ }; }).value(); if ($dropdown.hasClass('js-extra-options')) { + var extraData = []; if (showNo) { - data.unshift({ + extraData.unshift({ id: 0, title: 'No Label' }); } if (showAny) { - data.unshift({ + extraData.unshift({ isAny: true, title: 'Any Label' }); } - if (data.length > 2) { - data.splice(2, 0, 'divider'); + if (extraData.length) { + extraData.push('divider'); + data = extraData.concat(data); } } - return callback(data); + + callback(data); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }); }, renderRow: function(label, instance) { @@ -179,7 +172,7 @@ $li = $('<li>'); $a = $('<a href="#">'); selectedClass = []; - removesAll = label.id === 0 || (label.id == null); + removesAll = label.id <= 0 || (label.id == null); if ($dropdown.hasClass('js-filter-bulk-update')) { indeterminate = instance.indeterminateIds; active = instance.activeIds; @@ -187,15 +180,17 @@ selectedClass.push('is-indeterminate'); } if (active.indexOf(label.id) !== -1) { + // Remove is-indeterminate class if the item will be marked as active i = selectedClass.indexOf('is-indeterminate'); if (i !== -1) { selectedClass.splice(i, 1); } selectedClass.push('is-active'); + // Add input manually instance.addInput(this.fieldName, label.id); } } - if ($form.find("input[type='hidden'][name='" + ($dropdown.data('fieldName')) + "'][value='" + (this.id(label)) + "']").length) { + if (this.id(label) && $form.find("input[type='hidden'][name='" + ($dropdown.data('fieldName')) + "'][value='" + this.id(label).toString().replace(/'/g, '\\\'') + "']").length) { selectedClass.push('is-active'); } if ($dropdown.hasClass('js-multiselect') && removesAll) { @@ -203,6 +198,7 @@ } if (label.duplicate) { spacing = 100 / label.color.length; + // Reduce the colors to 4 label.color = label.color.filter(function(color, i) { return i < 4; }); @@ -213,21 +209,25 @@ return color + " " + percentFirst + "%," + color + " " + percentSecond + "% "; }).join(','); color = "linear-gradient(" + color + ")"; - } else { + } + else { if (label.color != null) { color = label.color[0]; } } if (color) { colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>"; - } else { + } + else { colorEl = ''; } + // We need to identify which items are actually labels if (label.id) { selectedClass.push('label-item'); $a.attr('data-label-id', label.id); } $a.addClass(selectedClass.join(' ')).html(colorEl + " " + label.title); + // Return generated html return $li.html($a).prop('outerHTML'); }, persistWhenHide: $dropdown.data('persistWhenHide'), @@ -236,30 +236,46 @@ }, selectable: true, filterable: true, + selected: $dropdown.data('selected') || [], toggleLabel: function(selected, el) { - var selected_labels; - selected_labels = $('.js-label-select').siblings('.dropdown-menu-labels').find('.is-active'); - if (selected && (selected.title != null)) { - if (selected_labels.length > 1) { - return selected.title + " +" + (selected_labels.length - 1) + " more"; - } else { - return selected.title; - } - } else if (!selected && selected_labels.length !== 0) { - if (selected_labels.length > 1) { - return ($(selected_labels[0]).text()) + " +" + (selected_labels.length - 1) + " more"; - } else if (selected_labels.length === 1) { - return $(selected_labels).text(); - } - } else { + var isSelected = el !== null ? el.hasClass('is-active') : false; + var title = selected.title; + var selectedLabels = this.selected; + + if (selected.id === 0) { + this.selected = []; + return 'No Label'; + } + else if (isSelected) { + this.selected.push(title); + } + else { + var index = this.selected.indexOf(title); + this.selected.splice(index, 1); + } + + if (selectedLabels.length === 1) { + return selectedLabels; + } + else if (selectedLabels.length) { + return selectedLabels[0] + " +" + (selectedLabels.length - 1) + " more"; + } + else { return defaultLabel; } }, fieldName: $dropdown.data('field-name'), id: function(label) { + if (label.id <= 0) return; + + if ($dropdown.hasClass('js-issuable-form-dropdown')) { + return label.id; + } + if ($dropdown.hasClass("js-filter-submit") && (label.isAny == null)) { return label.title; - } else { + } + else { return label.id; } }, @@ -269,46 +285,88 @@ isIssueIndex = page === 'projects:issues:index'; isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); + // display:block overrides the hide-collapse rule $value.removeAttr('style'); + + if ($dropdown.hasClass('js-issuable-form-dropdown')) { + return; + } + + if ($('html').hasClass('issue-boards-page')) { + return; + } if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']"); Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { + } + else if ($dropdown.hasClass('js-filter-submit')) { $dropdown.closest('form').submit(); - } else { + } + else { if (!$dropdown.hasClass('js-filter-bulk-update')) { saveLabelData(); } } } if ($dropdown.hasClass('js-filter-bulk-update')) { + // If we are persisting state we need the classes if (!this.options.persistWhenHide) { return $dropdown.parent().find('.is-active, .is-indeterminate').removeClass(); } } }, multiSelect: $dropdown.hasClass('js-multiselect'), - clicked: function(label) { + clicked: function(label, $el, e) { var isIssueIndex, isMRIndex, page; _this.enableBulkLabelDropdown(); - if ($dropdown.hasClass('js-filter-bulk-update')) { + + if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { + $dropdown.parent() + .find('.dropdown-clear-active') + .removeClass('is-active') + } + + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { return; } + page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = page === 'projects:merge_requests:index'; - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if ($('html').hasClass('issue-boards-page')) { + if (label.isAny) { + gl.issueBoards.BoardsStore.state.filters['label_name'] = []; + } + else if ($el.hasClass('is-active')) { + gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title); + } + else { + var filters = gl.issueBoards.BoardsStore.state.filters['label_name']; + filters = filters.filter(function (filteredLabel) { + return filteredLabel !== label.title; + }); + gl.issueBoards.BoardsStore.state.filters['label_name'] = filters; + } + + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + return; + } + else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (!$dropdown.hasClass('js-multiselect')) { selectedLabel = label.title; return Issuable.filterResults($dropdown.closest('form')); } - } else if ($dropdown.hasClass('js-filter-submit')) { + } + else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); - } else { + } + else { if ($dropdown.hasClass('js-multiselect')) { - } else { + } + else { return saveLabelData(); } } @@ -336,7 +394,9 @@ if ($('.selected_issue:checked').length) { return; } + // Remove inputs $('.issues_bulk_update .labels-filter input[type="hidden"]').remove(); + // Also restore button text return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label'); }; diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index ce472f3bcd0..8e2fc0d1479 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -10,11 +10,13 @@ }; $(function() { - hideEndFade($('.scrolling-tabs')); + var $scrollingTabs = $('.scrolling-tabs'); + + hideEndFade($scrollingTabs); $(window).off('resize.nav').on('resize.nav', function() { - return hideEndFade($('.scrolling-tabs')); + return hideEndFade($scrollingTabs); }); - return $('.scrolling-tabs').on('scroll', function(event) { + $scrollingTabs.off('scroll').on('scroll', function(event) { var $this, currentPosition, maxPosition; $this = $(this); currentPosition = $this.scrollLeft(); @@ -22,6 +24,23 @@ $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); return $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); }); + + $scrollingTabs.each(function () { + var $this = $(this), + scrollingTabWidth = $this.width(), + $active = $this.find('.active'), + activeWidth = $active.width(); + + if ($active.length) { + var offset = $active.offset().left + activeWidth; + + if (offset > scrollingTabWidth - 30) { + var scrollLeft = scrollingTabWidth / 2; + scrollLeft = (offset - scrollLeft) - (activeWidth / 2); + $this.scrollLeft(scrollLeft); + } + } + }); }); }).call(this); diff --git a/app/assets/javascripts/lib/ace.js b/app/assets/javascripts/lib/ace.js new file mode 100644 index 00000000000..4cdf99cae72 --- /dev/null +++ b/app/assets/javascripts/lib/ace.js @@ -0,0 +1,2 @@ +/*= require ace-rails-ap */ +/*= require ace/ext-searchbox */ diff --git a/app/assets/javascripts/lib/chart.js b/app/assets/javascripts/lib/chart.js index 8d5e52286b7..d9b07c10a49 100644 --- a/app/assets/javascripts/lib/chart.js +++ b/app/assets/javascripts/lib/chart.js @@ -3,5 +3,4 @@ (function() { - }).call(this); diff --git a/app/assets/javascripts/lib/cropper.js b/app/assets/javascripts/lib/cropper.js index 8ee81804513..a88e640f298 100644 --- a/app/assets/javascripts/lib/cropper.js +++ b/app/assets/javascripts/lib/cropper.js @@ -3,5 +3,4 @@ (function() { - }).call(this); diff --git a/app/assets/javascripts/lib/d3.js b/app/assets/javascripts/lib/d3.js index 31e6033e756..ee1baf54803 100644 --- a/app/assets/javascripts/lib/d3.js +++ b/app/assets/javascripts/lib/d3.js @@ -3,5 +3,4 @@ (function() { - }).call(this); diff --git a/app/assets/javascripts/lib/raphael.js b/app/assets/javascripts/lib/raphael.js index 923c575dcfe..6df427bc2b1 100644 --- a/app/assets/javascripts/lib/raphael.js +++ b/app/assets/javascripts/lib/raphael.js @@ -1,13 +1,8 @@ /*= require raphael */ - - /*= require g.raphael */ - - /*= require g.bar */ (function() { - }).call(this); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 9299d0eabd2..b170e26eebf 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -38,6 +38,11 @@ gl.utils.getPagePath = function() { return $('body').data('page').split(':')[0]; }; + gl.utils.parseUrl = function (url) { + var parser = document.createElement('a'); + parser.href = url; + return parser; + }; return jQuery.timefor = function(time, suffix, expiredLabel) { var suffixFromNow, timefor; if (!time) { diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index e817261f210..8fdf4646cd8 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -8,13 +8,16 @@ base.utils = {}; } w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + w.gl.utils.formatDate = function(datetime) { return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); }; + w.gl.utils.getDayName = function(date) { return this.days[date.getDay()]; }; - return w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) { + + w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) { if (setTimeago == null) { setTimeago = true; } @@ -26,11 +29,53 @@ if (setTimeago) { $timeagoEls.timeago(); $timeagoEls.tooltip('destroy'); + // Recreate with custom template return $timeagoEls.tooltip({ template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>' }); } }; + + w.gl.utils.shortTimeAgo = function($el) { + var shortLocale, tmpLocale; + shortLocale = { + prefixAgo: null, + prefixFromNow: null, + suffixAgo: 'ago', + suffixFromNow: 'from now', + seconds: '1 min', + minute: '1 min', + minutes: '%d mins', + hour: '1 hr', + hours: '%d hrs', + day: '1 day', + days: '%d days', + month: '1 month', + months: '%d months', + year: '1 year', + years: '%d years', + wordSeparator: ' ', + numbers: [] + }; + tmpLocale = $.timeago.settings.strings; + $el.each(function(el) { + var $el1; + $el1 = $(this); + return $el1.attr('title', gl.utils.formatDate($el.attr('datetime'))); + }); + $.timeago.settings.strings = shortLocale; + $el.timeago(); + $.timeago.settings.strings = tmpLocale; + }; + + w.gl.utils.getDayDifference = function(a, b) { + var millisecondsPerDay = 1000 * 60 * 60 * 24; + var date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + var date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + + return Math.floor((date2 - date1) / millisecondsPerDay); + } + })(window); }).call(this); diff --git a/app/assets/javascripts/lib/utils/emoji_aliases.js.coffee.erb b/app/assets/javascripts/lib/utils/emoji_aliases.js.coffee.erb deleted file mode 100644 index 80f9936b9c2..00000000000 --- a/app/assets/javascripts/lib/utils/emoji_aliases.js.coffee.erb +++ /dev/null @@ -1,2 +0,0 @@ -gl.emojiAliases = -> - JSON.parse('<%= Gitlab::AwardEmoji.aliases.to_json %>') diff --git a/app/assets/javascripts/lib/utils/emoji_aliases.js.erb b/app/assets/javascripts/lib/utils/emoji_aliases.js.erb new file mode 100644 index 00000000000..aeb86c9fa5b --- /dev/null +++ b/app/assets/javascripts/lib/utils/emoji_aliases.js.erb @@ -0,0 +1,6 @@ +(function() { + gl.emojiAliases = function() { + return JSON.parse('<%= Gitlab::AwardEmoji.aliases.to_json %>'); + }; + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index 42b6ac0589e..5b338b00d76 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -6,6 +6,7 @@ notification = new Notification(message, opts); setTimeout(function() { return notification.close(); + // Hide the notification after X amount of seconds }, 8000); if (onclick) { return notification.onclick = onclick; @@ -22,12 +23,16 @@ body: body, icon: icon }; + // Let's check if the browser supports notifications if (!('Notification' in window)) { + // do nothing } else if (Notification.permission === 'granted') { + // If it's okay let's create a notification return notificationGranted(message, opts, onclick); } else if (Notification.permission !== 'denied') { return Notification.requestPermission(function(permission) { + // If the user accepts, let's create a notification if (permission === 'granted') { return notificationGranted(message, opts, onclick); } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 130479642f3..d761a844be9 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -29,6 +29,7 @@ lineBefore = this.lineBefore(text, textArea); lineAfter = this.lineAfter(text, textArea); if (lineBefore === blockTag && lineAfter === blockTag) { + // To remove the block tag we have to select the line before & after if (blockTag != null) { textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); @@ -63,11 +64,11 @@ if (!inserted) { try { document.execCommand("ms-beginUndoUnit"); - } catch (undefined) {} + } catch (error) {} textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); try { document.execCommand("ms-endUndoUnit"); - } catch (undefined) {} + } catch (error) {} } return this.moveCursor(textArea, tag, wrap); }; @@ -104,9 +105,12 @@ return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); }); }; - return gl.text.removeListeners = function(form) { + gl.text.removeListeners = function(form) { return $('.js-md', form).off(); }; + return gl.text.truncate = function(string, maxLength) { + return string.substr(0, (maxLength - 3)) + '...'; + }; })(window); }).call(this); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index fffbfd19745..b8d52becb3f 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -7,6 +7,8 @@ if ((base = w.gl).utils == null) { base.utils = {}; } + // Returns an array containing the value(s) of the + // of the key passed as an argument w.gl.utils.getParameterValues = function(sParam) { var i, sPageURL, sParameterName, sURLVariables, values; sPageURL = decodeURIComponent(window.location.search.substring(1)); @@ -17,12 +19,14 @@ while (i < sURLVariables.length) { sParameterName = sURLVariables[i].split('='); if (sParameterName[0] === sParam) { - values.push(sParameterName[1]); + values.push(sParameterName[1].replace(/\+/g, ' ')); } i++; } return values; }; + // @param {Object} params - url keys and value to merge + // @param {String} url w.gl.utils.mergeUrlParams = function(params, url) { var lastChar, newUrl, paramName, paramValue, pattern; newUrl = decodeURIComponent(url); @@ -37,13 +41,15 @@ newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; } } + // Remove a trailing ampersand lastChar = newUrl[newUrl.length - 1]; if (lastChar === '&') { newUrl = newUrl.slice(0, -1); } return newUrl; }; - return w.gl.utils.removeParamQueryString = function(url, param) { + // removes parameter query string from url. returns the modified url + w.gl.utils.removeParamQueryString = function(url, param) { var urlVariables, variables; url = decodeURIComponent(url); urlVariables = url.split('&'); @@ -59,6 +65,16 @@ return results; })()).join('&'); }; + w.gl.utils.getLocationHash = function(url) { + var hashIndex; + if (typeof url === 'undefined') { + // Note: We can't use window.location.hash here because it's + // not consistent across browsers - Firefox will pre-decode it + url = window.location.href; + } + hashIndex = url.indexOf('#'); + return hashIndex === -1 ? null : url.substring(hashIndex + 1); + }; })(window); }).call(this); diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index f145bd3ad74..93daea1dce7 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,17 +1,49 @@ - +// LineHighlighter +// +// Handles single- and multi-line selection and highlight for blob views. +// /*= require jquery.scrollTo */ +// +// ### Example Markup +// +// <div id="blob-content-holder"> +// <div class="file-content"> +// <div class="line-numbers"> +// <a href="#L1" id="L1" data-line-number="1">1</a> +// <a href="#L2" id="L2" data-line-number="2">2</a> +// <a href="#L3" id="L3" data-line-number="3">3</a> +// <a href="#L4" id="L4" data-line-number="4">4</a> +// <a href="#L5" id="L5" data-line-number="5">5</a> +// </div> +// <pre class="code highlight"> +// <code> +// <span id="LC1" class="line">...</span> +// <span id="LC2" class="line">...</span> +// <span id="LC3" class="line">...</span> +// <span id="LC4" class="line">...</span> +// <span id="LC5" class="line">...</span> +// </code> +// </pre> +// </div> +// </div> +// (function() { var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; this.LineHighlighter = (function() { + // CSS class applied to highlighted lines LineHighlighter.prototype.highlightClass = 'hll'; + // Internal copy of location.hash so we're not dependent on `location` in tests LineHighlighter.prototype._hash = ''; function LineHighlighter(hash) { var range; if (hash == null) { + // Initialize a LineHighlighter object + // + // hash - String URL hash for dependency injection in tests hash = location.hash; } this.setHash = bind(this.setHash, this); @@ -24,6 +56,8 @@ if (range[0]) { this.highlightRange(range); $.scrollTo("#L" + range[0], { + // Scroll to the first highlighted line on initial load + // Offset -50 for the sticky top bar, and another -100 for some context offset: -150 }); } @@ -32,6 +66,12 @@ LineHighlighter.prototype.bindEvents = function() { $('#blob-content-holder').on('mousedown', 'a[data-line-number]', this.clickHandler); + // While it may seem odd to bind to the mousedown event and then throw away + // the click event, there is a method to our madness. + // + // If not done this way, the line number anchor will sometimes keep its + // active state even when the event is cancelled, resulting in an ugly border + // around the link and/or a persisted underline text decoration. return $('#blob-content-holder').on('click', 'a[data-line-number]', function(event) { return event.preventDefault(); }); @@ -44,6 +84,8 @@ lineNumber = $(event.target).closest('a').data('line-number'); current = this.hashToRange(this._hash); if (!(current[0] && event.shiftKey)) { + // If there's no current selection, or there is but Shift wasn't held, + // treat this like a single-line selection. this.setHash(lineNumber); return this.highlightLine(lineNumber); } else if (event.shiftKey) { @@ -59,10 +101,23 @@ LineHighlighter.prototype.clearHighlight = function() { return $("." + this.highlightClass).removeClass(this.highlightClass); + // Unhighlight previously highlighted lines }; + // Convert a URL hash String into line numbers + // + // hash - Hash String + // + // Examples: + // + // hashToRange('#L5') # => [5, null] + // hashToRange('#L5-15') # => [5, 15] + // hashToRange('#foo') # => [null, null] + // + // Returns an Array LineHighlighter.prototype.hashToRange = function(hash) { var first, last, matches; + //?L(\d+)(?:-(\d+))?$/) matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); if (matches && matches.length) { first = parseInt(matches[1]); @@ -73,10 +128,16 @@ } }; + // Highlight a single line + // + // lineNumber - Line number to highlight LineHighlighter.prototype.highlightLine = function(lineNumber) { return $("#LC" + lineNumber).addClass(this.highlightClass); }; + // Highlight all lines within a range + // + // range - Array containing the starting and ending line numbers LineHighlighter.prototype.highlightRange = function(range) { var i, lineNumber, ref, ref1, results; if (range[1]) { @@ -90,6 +151,7 @@ } }; + // Set the URL hash string LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { var hash; if (lastLineNumber) { @@ -101,10 +163,15 @@ return this.__setLocationHash__(hash); }; + // Make the actual hash change in the browser + // + // This method is stubbed in tests. LineHighlighter.prototype.__setLocationHash__ = function(value) { return history.pushState({ turbolinks: false, url: value + // We're using pushState instead of assigning location.hash directly to + // prevent the page from scrolling on the hashchange event }, document.title, value); }; diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js index 218f24fe908..7d8eef1b495 100644 --- a/app/assets/javascripts/logo.js +++ b/app/assets/javascripts/logo.js @@ -1,54 +1,12 @@ (function() { - var clearHighlights, currentTimer, defaultClass, delay, firstPiece, pieceIndex, pieces, start, stop, work; - Turbolinks.enableProgressBar(); - defaultClass = 'tanuki-shape'; - - pieces = ['path#tanuki-right-cheek', 'path#tanuki-right-eye, path#tanuki-right-ear', 'path#tanuki-nose', 'path#tanuki-left-eye, path#tanuki-left-ear', 'path#tanuki-left-cheek']; - - pieceIndex = 0; - - firstPiece = pieces[0]; - - currentTimer = null; - - delay = 150; - - clearHighlights = function() { - return $("." + defaultClass + ".highlight").attr('class', defaultClass); - }; - - start = function() { - clearHighlights(); - pieceIndex = 0; - if (pieces[0] !== firstPiece) { - pieces.reverse(); - } - if (currentTimer) { - clearInterval(currentTimer); - } - return currentTimer = setInterval(work, delay); - }; - - stop = function() { - clearInterval(currentTimer); - return clearHighlights(); - }; - - work = function() { - clearHighlights(); - $(pieces[pieceIndex]).attr('class', defaultClass + " highlight"); - if (pieceIndex === pieces.length - 1) { - pieceIndex = 0; - return pieces.reverse(); - } else { - return pieceIndex++; - } - }; - - $(document).on('page:fetch', start); + $(document).on('page:fetch', function() { + $('.tanuki-logo').addClass('animate'); + }); - $(document).on('page:change', stop); + $(document).on('page:change', function() { + $('.tanuki-logo').removeClass('animate'); + }); }).call(this); diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js new file mode 100644 index 00000000000..1935af491f7 --- /dev/null +++ b/app/assets/javascripts/member_expiration_date.js @@ -0,0 +1,32 @@ +(function() { + // Add datepickers to all `js-access-expiration-date` elements. If those elements are + // children of an element with the `clearable-input` class, and have a sibling + // `js-clear-input` element, then show that element when there is a value in the + // datepicker, and make clicking on that element clear the field. + // + gl.MemberExpirationDate = function() { + function toggleClearInput() { + $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== ''); + } + + var inputs = $('.js-access-expiration-date'); + + inputs.datepicker({ + dateFormat: 'yy-mm-dd', + minDate: 1, + onSelect: toggleClearInput + }); + + inputs.next('.js-clear-input').on('click', function(event) { + event.preventDefault(); + + var input = $(this).closest('.clearable-input').find('.js-access-expiration-date'); + input.datepicker('setDate', null); + toggleClearInput.call(input); + }); + + inputs.on('blur', toggleClearInput); + + inputs.each(toggleClearInput); + }; +}).call(this); diff --git a/app/assets/javascripts/merge_conflict_data_provider.js.es6 b/app/assets/javascripts/merge_conflict_data_provider.js.es6 new file mode 100644 index 00000000000..13ee794ba38 --- /dev/null +++ b/app/assets/javascripts/merge_conflict_data_provider.js.es6 @@ -0,0 +1,347 @@ +const HEAD_HEADER_TEXT = 'HEAD//our changes'; +const ORIGIN_HEADER_TEXT = 'origin//their changes'; +const HEAD_BUTTON_TITLE = 'Use ours'; +const ORIGIN_BUTTON_TITLE = 'Use theirs'; + + +class MergeConflictDataProvider { + + getInitialData() { + // TODO: remove reliance on jQuery and DOM state introspection + const diffViewType = $.cookie('diff_view'); + const fixedLayout = $('.content-wrapper .container-fluid').hasClass('container-limited'); + + return { + isLoading : true, + hasError : false, + isParallel : diffViewType === 'parallel', + diffViewType : diffViewType, + fixedLayout : fixedLayout, + isSubmitting : false, + conflictsData : {}, + resolutionData : {} + } + } + + + decorateData(vueInstance, data) { + this.vueInstance = vueInstance; + + if (data.type === 'error') { + vueInstance.hasError = true; + data.errorMessage = data.message; + } + else { + data.shortCommitSha = data.commit_sha.slice(0, 7); + data.commitMessage = data.commit_message; + + this.setParallelLines(data); + this.setInlineLines(data); + this.updateResolutionsData(data); + } + + vueInstance.conflictsData = data; + vueInstance.isSubmitting = false; + + const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict'; + vueInstance.conflictsData.conflictsText = conflictsText; + } + + + updateResolutionsData(data) { + const vi = this.vueInstance; + + data.files.forEach( (file) => { + file.sections.forEach( (section) => { + if (section.conflict) { + vi.$set(`resolutionData['${section.id}']`, false); + } + }); + }); + } + + + setParallelLines(data) { + data.files.forEach( (file) => { + file.filePath = this.getFilePath(file); + file.iconClass = `fa-${file.blob_icon}`; + file.blobPath = file.blob_path; + file.parallelLines = []; + const linesObj = { left: [], right: [] }; + + file.sections.forEach( (section) => { + const { conflict, lines, id } = section; + + if (conflict) { + linesObj.left.push(this.getOriginHeaderLine(id)); + linesObj.right.push(this.getHeadHeaderLine(id)); + } + + lines.forEach( (line) => { + const { type } = line; + + if (conflict) { + if (type === 'old') { + linesObj.left.push(this.getLineForParallelView(line, id, 'conflict')); + } + else if (type === 'new') { + linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true)); + } + } + else { + const lineType = type || 'context'; + + linesObj.left.push (this.getLineForParallelView(line, id, lineType)); + linesObj.right.push(this.getLineForParallelView(line, id, lineType, true)); + } + }); + + this.checkLineLengths(linesObj); + }); + + for (let i = 0, len = linesObj.left.length; i < len; i++) { + file.parallelLines.push([ + linesObj.right[i], + linesObj.left[i] + ]); + } + + }); + } + + + checkLineLengths(linesObj) { + let { left, right } = linesObj; + + if (left.length !== right.length) { + if (left.length > right.length) { + const diff = left.length - right.length; + for (let i = 0; i < diff; i++) { + right.push({ lineType: 'emptyLine', richText: '' }); + } + } + else { + const diff = right.length - left.length; + for (let i = 0; i < diff; i++) { + left.push({ lineType: 'emptyLine', richText: '' }); + } + } + } + } + + + setInlineLines(data) { + data.files.forEach( (file) => { + file.iconClass = `fa-${file.blob_icon}`; + file.blobPath = file.blob_path; + file.filePath = this.getFilePath(file); + file.inlineLines = [] + + file.sections.forEach( (section) => { + let currentLineType = 'new'; + const { conflict, lines, id } = section; + + if (conflict) { + file.inlineLines.push(this.getHeadHeaderLine(id)); + } + + lines.forEach( (line) => { + const { type } = line; + + if ((type === 'new' || type === 'old') && currentLineType !== type) { + currentLineType = type; + file.inlineLines.push({ lineType: 'emptyLine', richText: '' }); + } + + this.decorateLineForInlineView(line, id, conflict); + file.inlineLines.push(line); + }) + + if (conflict) { + file.inlineLines.push(this.getOriginHeaderLine(id)); + } + }); + }); + } + + + handleSelected(sectionId, selection) { + const vi = this.vueInstance; + + vi.resolutionData[sectionId] = selection; + vi.conflictsData.files.forEach( (file) => { + file.inlineLines.forEach( (line) => { + if (line.id === sectionId && (line.hasConflict || line.isHeader)) { + this.markLine(line, selection); + } + }); + + file.parallelLines.forEach( (lines) => { + const left = lines[0]; + const right = lines[1]; + const hasSameId = right.id === sectionId || left.id === sectionId; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (hasSameId && (isLeftMatch || isRightMatch)) { + this.markLine(left, selection); + this.markLine(right, selection); + } + }) + }); + } + + + updateViewType(newType) { + const vi = this.vueInstance; + + if (newType === vi.diffViewType || !(newType === 'parallel' || newType === 'inline')) { + return; + } + + vi.diffViewType = newType; + vi.isParallel = newType === 'parallel'; + $.cookie('diff_view', newType, { + path: (gon && gon.relative_url_root) || '/' + }); + $('.content-wrapper .container-fluid') + .toggleClass('container-limited', !vi.isParallel && vi.fixedLayout); + } + + + markLine(line, selection) { + if (selection === 'head' && line.isHead) { + line.isSelected = true; + line.isUnselected = false; + } + else if (selection === 'origin' && line.isOrigin) { + line.isSelected = true; + line.isUnselected = false; + } + else { + line.isSelected = false; + line.isUnselected = true; + } + } + + + getConflictsCount() { + return Object.keys(this.vueInstance.resolutionData).length; + } + + + getResolvedCount() { + let count = 0; + const data = this.vueInstance.resolutionData; + + for (const id in data) { + const resolution = data[id]; + if (resolution) { + count++; + } + } + + return count; + } + + + isReadyToCommit() { + const { conflictsData, isSubmitting } = this.vueInstance + const allResolved = this.getConflictsCount() === this.getResolvedCount(); + const hasCommitMessage = $.trim(conflictsData.commitMessage).length; + + return !isSubmitting && hasCommitMessage && allResolved; + } + + + getCommitButtonText() { + const initial = 'Commit conflict resolution'; + const inProgress = 'Committing...'; + const vue = this.vueInstance; + + return vue ? vue.isSubmitting ? inProgress : initial : initial; + } + + + decorateLineForInlineView(line, id, conflict) { + const { type } = line; + line.id = id; + line.hasConflict = conflict; + line.isHead = type === 'new'; + line.isOrigin = type === 'old'; + line.hasMatch = type === 'match'; + line.richText = line.rich_text; + line.isSelected = false; + line.isUnselected = false; + } + + getLineForParallelView(line, id, lineType, isHead) { + const { old_line, new_line, rich_text } = line; + const hasConflict = lineType === 'conflict'; + + return { + id, + lineType, + hasConflict, + isHead : hasConflict && isHead, + isOrigin : hasConflict && !isHead, + hasMatch : lineType === 'match', + lineNumber : isHead ? new_line : old_line, + section : isHead ? 'head' : 'origin', + richText : rich_text, + isSelected : false, + isUnselected : false + } + } + + + getHeadHeaderLine(id) { + return { + id : id, + richText : HEAD_HEADER_TEXT, + buttonTitle : HEAD_BUTTON_TITLE, + type : 'new', + section : 'head', + isHeader : true, + isHead : true, + isSelected : false, + isUnselected: false + } + } + + + getOriginHeaderLine(id) { + return { + id : id, + richText : ORIGIN_HEADER_TEXT, + buttonTitle : ORIGIN_BUTTON_TITLE, + type : 'old', + section : 'origin', + isHeader : true, + isOrigin : true, + isSelected : false, + isUnselected: false + } + } + + + handleFailedRequest(vueInstance, data) { + vueInstance.hasError = true; + vueInstance.conflictsData.errorMessage = 'Something went wrong!'; + } + + + getCommitData() { + return { + commit_message: this.vueInstance.conflictsData.commitMessage, + sections: this.vueInstance.resolutionData + } + } + + + getFilePath(file) { + const { old_path, new_path } = file; + return old_path === new_path ? new_path : `${old_path} → ${new_path}`; + } + +} diff --git a/app/assets/javascripts/merge_conflict_resolver.js.es6 b/app/assets/javascripts/merge_conflict_resolver.js.es6 new file mode 100644 index 00000000000..7e756433bf5 --- /dev/null +++ b/app/assets/javascripts/merge_conflict_resolver.js.es6 @@ -0,0 +1,82 @@ +//= require vue + +class MergeConflictResolver { + + constructor() { + this.dataProvider = new MergeConflictDataProvider() + this.initVue() + } + + + initVue() { + const that = this; + this.vue = new Vue({ + el : '#conflicts', + name : 'MergeConflictResolver', + data : this.dataProvider.getInitialData(), + created : this.fetchData(), + computed : this.setComputedProperties(), + methods : { + handleSelected(sectionId, selection) { + that.dataProvider.handleSelected(sectionId, selection); + }, + handleViewTypeChange(newType) { + that.dataProvider.updateViewType(newType); + }, + commit() { + that.commit(); + } + } + }) + } + + + setComputedProperties() { + const dp = this.dataProvider; + + return { + conflictsCount() { return dp.getConflictsCount() }, + resolvedCount() { return dp.getResolvedCount() }, + readyToCommit() { return dp.isReadyToCommit() }, + commitButtonText() { return dp.getCommitButtonText() } + } + } + + + fetchData() { + const dp = this.dataProvider; + + $.get($('#conflicts').data('conflictsPath')) + .done((data) => { + dp.decorateData(this.vue, data); + }) + .error((data) => { + dp.handleFailedRequest(this.vue, data); + }) + .always(() => { + this.vue.isLoading = false; + + this.vue.$nextTick(() => { + $('#conflicts .js-syntax-highlight').syntaxHighlight(); + }); + + $('.content-wrapper .container-fluid') + .toggleClass('container-limited', !this.vue.isParallel && this.vue.fixedLayout); + }) + } + + + commit() { + this.vue.isSubmitting = true; + + $.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData()) + .done((data) => { + window.location.href = data.redirect_to; + }) + .error(() => { + this.vue.isSubmitting = false; + new Flash('Something went wrong!'); + }); + } + +} diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 47e6dd1084d..02ff5a382e2 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,10 +1,6 @@ /*= require jquery.waitforimages */ - - /*= require task_list */ - - /*= require merge_request_tabs */ (function() { @@ -12,6 +8,11 @@ this.MergeRequest = (function() { function MergeRequest(opts) { + // Initialize MergeRequest behavior + // + // Options: + // action - String, current controller action + // this.opts = opts != null ? opts : {}; this.submitNoteForm = bind(this.submitNoteForm, this); this.$el = $('.merge-request'); @@ -21,6 +22,7 @@ }; })(this)); this.initTabs(); + // Prevent duplicate event bindings this.disableTaskList(); this.initMRBtnListeners(); if ($("a.btn-close").length) { @@ -28,16 +30,16 @@ } } + // Local jQuery finder MergeRequest.prototype.$ = function(selector) { return this.$el.find(selector); }; MergeRequest.prototype.initTabs = function() { - if (this.opts.action !== 'new') { - return new MergeRequestTabs(this.opts); - } else { - return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show'); + if (window.mrTabs) { + window.mrTabs.unbindEvents(); } + window.mrTabs = new MergeRequestTabs(this.opts); }; MergeRequest.prototype.showAllCommits = function() { @@ -96,6 +98,8 @@ url: $('form.js-issuable-update').attr('action'), data: patchData }); + // TODO (rspeicher): Make the merge request description inline-editable like a + // note so that we can re-use its form here }; return MergeRequest; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 52c2ed61012..8045d24a1bb 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,6 +1,49 @@ - +// MergeRequestTabs +// +// Handles persisting and restoring the current tab selection and lazily-loading +// content on the MergeRequests#show page. +// /*= require jquery.cookie */ +// +// ### Example Markup +// +// <ul class="nav-links merge-request-tabs"> +// <li class="notes-tab active"> +// <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/merge_requests/1"> +// Discussion +// </a> +// </li> +// <li class="commits-tab"> +// <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/merge_requests/1/commits"> +// Commits +// </a> +// </li> +// <li class="diffs-tab"> +// <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/merge_requests/1/diffs"> +// Diffs +// </a> +// </li> +// </ul> +// +// <div class="tab-content"> +// <div class="notes tab-pane active" id="notes"> +// Notes Content +// </div> +// <div class="commits tab-pane" id="commits"> +// Commits Content +// </div> +// <div class="diffs tab-pane" id="diffs"> +// Diffs Content +// </div> +// </div> +// +// <div class="mr-loading-status"> +// <div class="loading"> +// Loading Animation +// </div> +// </div> +// (function() { var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; @@ -9,13 +52,22 @@ MergeRequestTabs.prototype.buildsLoaded = false; + MergeRequestTabs.prototype.pipelinesLoaded = false; + MergeRequestTabs.prototype.commitsLoaded = false; + MergeRequestTabs.prototype.fixedLayoutPref = null; + function MergeRequestTabs(opts) { this.opts = opts != null ? opts : {}; + this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true; + + this.buildsLoaded = this.opts.buildsLoaded || false; + this.setCurrentAction = bind(this.setCurrentAction, this); this.tabShown = bind(this.tabShown, this); this.showTab = bind(this.showTab, this); + // Store the `location` object, allowing for easier stubbing in tests this._location = location; this.bindEvents(); this.activateTab(this.opts.action); @@ -23,7 +75,12 @@ MergeRequestTabs.prototype.bindEvents = function() { $(document).on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown); - return $(document).on('click', '.js-show-tab', this.showTab); + $(document).on('click', '.js-show-tab', this.showTab); + }; + + MergeRequestTabs.prototype.unbindEvents = function() { + $(document).off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown); + $(document).off('click', '.js-show-tab', this.showTab); }; MergeRequestTabs.prototype.showTab = function(event) { @@ -38,11 +95,15 @@ if (action === 'commits') { this.loadCommits($target.attr('href')); this.expandView(); - } else if (action === 'diffs') { + this.resetViewContainer(); + } else if (this.isDiffAction(action)) { this.loadDiff($target.attr('href')); if ((typeof bp !== "undefined" && bp !== null) && bp.getBreakpointSize() !== 'lg') { this.shrinkView(); } + if (this.diffViewType() === 'parallel') { + this.expandViewContainer(); + } navBarHeight = $('.navbar-gitlab').outerHeight(); $.scrollTo(".merge-request-details .merge-request-tabs", { offset: -navBarHeight @@ -50,10 +111,18 @@ } else if (action === 'builds') { this.loadBuilds($target.attr('href')); this.expandView(); + this.resetViewContainer(); + } else if (action === 'pipelines') { + this.loadPipelines($target.attr('href')); + this.expandView(); + this.resetViewContainer(); } else { this.expandView(); + this.resetViewContainer(); + } + if (this.opts.setUrl) { + this.setCurrentAction(action); } - return this.setCurrentAction(action); }; MergeRequestTabs.prototype.scrollToElement = function(container) { @@ -69,26 +138,57 @@ } }; + // Activate a tab based on the current action MergeRequestTabs.prototype.activateTab = function(action) { if (action === 'show') { action = 'notes'; } - return $(".merge-request-tabs a[data-action='" + action + "']").tab('show'); + $(".merge-request-tabs a[data-action='" + action + "']").tab('show').trigger('shown.bs.tab'); }; + // Replaces the current Merge Request-specific action in the URL with a new one + // + // If the action is "notes", the URL is reset to the standard + // `MergeRequests#show` route. + // + // Examples: + // + // location.pathname # => "/namespace/project/merge_requests/1" + // setCurrentAction('diffs') + // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // + // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // setCurrentAction('notes') + // location.pathname # => "/namespace/project/merge_requests/1" + // + // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // setCurrentAction('commits') + // location.pathname # => "/namespace/project/merge_requests/1/commits" + // + // Returns the new URL String MergeRequestTabs.prototype.setCurrentAction = function(action) { var new_state; + // Normalize action, just to be safe if (action === 'show') { action = 'notes'; } - new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, ''); + this.currentAction = action; + // Remove a trailing '/commits' '/diffs' '/builds' '/pipelines' '/new' '/new/diffs' + new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines|new|new\/diffs)(\.html)?\/?$/, ''); + + // Append the new action if we're on a tab other than 'notes' if (action !== 'notes') { new_state += "/" + action; } + // Ensure parameters and hash come along for the ride new_state += this._location.search + this._location.hash; history.replaceState({ turbolinks: true, url: new_state + // Replace the current history state with the new one without breaking + // Turbolinks' history. + // + // See https://github.com/rails/turbolinks/issues/363 }, document.title, new_state); return new_state; }; @@ -114,15 +214,25 @@ if (this.diffsLoaded) { return; } + + // We extract pathname for the current Changes tab anchor href + // some pages like MergeRequestsController#new has query parameters on that anchor + var url = gl.utils.parseUrl(source); + return this._get({ - url: (source + ".json") + this._location.search, + url: (url.pathname + ".json") + this._location.search, success: (function(_this) { return function(data) { $('#diffs').html(data.html); + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } + gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')); $('#diffs .js-syntax-highlight').syntaxHighlight(); $('#diffs .diff-file').singleFileDiff(); - if (_this.diffViewType() === 'parallel') { + if (_this.diffViewType() === 'parallel' && (_this.isDiffAction(_this.currentAction)) ) { _this.expandViewContainer(); } _this.diffsLoaded = true; @@ -145,10 +255,10 @@ $('.hll').removeClass('hll'); locationHash = window.location.hash; if (locationHash !== '') { - hashClassString = "." + (locationHash.replace('#', '')); + dataLineString = '[data-line-code="' + locationHash.replace('#', '') + '"]'; $diffLine = $(locationHash + ":not(.match)", $('#diffs')); if (!$diffLine.is('tr')) { - $diffLine = $('#diffs').find("td" + locationHash + ", td" + hashClassString); + $diffLine = $('#diffs').find("td" + locationHash + ", td" + dataLineString); } else { $diffLine = $diffLine.find('td'); } @@ -177,6 +287,24 @@ }); }; + MergeRequestTabs.prototype.loadPipelines = function(source) { + if (this.pipelinesLoaded) { + return; + } + return this._get({ + url: source + ".json", + success: function(data) { + $('#pipelines').html(data.html); + gl.utils.localTimeAgo($('.js-timeago', '#pipelines')); + this.pipelinesLoaded = true; + return this.scrollToElement("#pipelines"); + }.bind(this) + }); + }; + + // Show or hide the loading spinner + // + // status - Boolean, true to show, false to hide MergeRequestTabs.prototype.toggleLoading = function(status) { return $('.mr-loading-status .loading').toggle(status); }; @@ -205,8 +333,23 @@ return $('.inline-parallel-buttons a.active').data('view-type'); }; + MergeRequestTabs.prototype.isDiffAction = function(action) { + return action === 'diffs' || action === 'new/diffs' + }; + MergeRequestTabs.prototype.expandViewContainer = function() { - return $('.container-fluid').removeClass('container-limited'); + var $wrapper = $('.content-wrapper .container-fluid'); + if (this.fixedLayoutPref === null) { + this.fixedLayoutPref = $wrapper.hasClass('container-limited'); + } + $wrapper.removeClass('container-limited'); + }; + + MergeRequestTabs.prototype.resetViewContainer = function() { + if (this.fixedLayoutPref !== null) { + $('.content-wrapper .container-fluid') + .toggleClass('container-limited', this.fixedLayoutPref); + } }; MergeRequestTabs.prototype.shrinkView = function() { @@ -216,6 +359,8 @@ if ($gutterIcon.is('.fa-angle-double-right')) { return $gutterIcon.closest('a').trigger('click', [true]); } + // Wait until listeners are set + // Only when sidebar is expanded }, 0); }; @@ -230,6 +375,9 @@ return $gutterIcon.closest('a').trigger('click', [true]); } }, 0); + // Expand the issuable sidebar unless the user explicitly collapsed it + // Wait until listeners are set + // Only when sidebar is collapsed }; return MergeRequestTabs; diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js.es6 index 362aaa906d0..fcadc4bc515 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js.es6 @@ -1,20 +1,51 @@ -(function() { + ((global) => { var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - this.MergeRequestWidget = (function() { + const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>"> + <div class="ci_widget ci-success"> + <%= ci_success_icon %> + <span> + Deployed to + <a href="<%- url %>" target="_blank" class="environment"> + <%- name %> + </a> + <span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>"> + <%- deployed_at %> + </span> + <a class="js-environment-link" href="<%- external_url %>" target="_blank"> + <i class="fa fa-external-link"></i> + View on <%- external_url_formatted %> + </a> + </span> + </div> + </div>`; + + global.MergeRequestWidget = (function() { function MergeRequestWidget(opts) { + // Initialize MergeRequestWidget behavior + // + // check_enable - Boolean, whether to check automerge status + // merge_check_url - String, URL to use to check automerge status + // ci_status_url - String, URL to use to check CI status + // this.opts = opts; + this.$widgetBody = $('.mr-widget-body'); $('#modal_merge_info').modal({ show: false }); this.firstCICheck = true; this.readyForCICheck = false; + this.readyForCIEnvironmentCheck = false; this.cancel = false; clearInterval(this.fetchBuildStatusInterval); + clearInterval(this.fetchBuildEnvironmentStatusInterval); this.clearEventListeners(); this.addEventListeners(); this.getCIStatus(false); + this.getCIEnvironmentsStatus(); + this.retrieveSuccessIcon(); this.pollCIStatus(); + this.pollCIEnvironmentsStatus(); notifyPermissions(); } @@ -28,13 +59,14 @@ MergeRequestWidget.prototype.addEventListeners = function() { var allowedPages; - allowedPages = ['show', 'commits', 'builds', 'changes']; + allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes']; return $(document).on('page:change.merge_request', (function(_this) { return function() { var page; page = $('body').data('page').split(':').last(); if (allowedPages.indexOf(page) < 0) { clearInterval(_this.fetchBuildStatusInterval); + clearInterval(_this.fetchBuildEnvironmentStatusInterval); _this.cancelPolling(); return _this.clearEventListeners(); } @@ -42,6 +74,12 @@ })(this)); }; + MergeRequestWidget.prototype.retrieveSuccessIcon = function() { + const $ciSuccessIcon = $('.js-success-icon'); + this.$ciSuccessIcon = $ciSuccessIcon.html(); + $ciSuccessIcon.remove(); + } + MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) { if (deleteSourceBranch == null) { deleteSourceBranch = false; @@ -53,10 +91,10 @@ return function(data) { var callback, urlSuffix; if (data.state === "merged") { - urlSuffix = deleteSourceBranch ? '?delete_source=true' : ''; + urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : ''; return window.location.href = window.location.pathname + urlSuffix; } else if (data.merge_error) { - return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>"); + return this.$widgetBody.html("<h4>" + data.merge_error + "</h4>"); } else { callback = function() { return merge_request_widget.mergeInProgress(deleteSourceBranch); @@ -112,12 +150,15 @@ if (data.status === '') { return; } + if (data.environments && data.environments.length) _this.renderEnvironments(data.environments); if (_this.firstCICheck || data.status !== _this.opts.ci_status && (data.status != null)) { _this.opts.ci_status = data.status; _this.showCIStatus(data.status); if (data.coverage) { _this.showCICoverage(data.coverage); } + // The first check should only update the UI, a notification + // should only be displayed on status changes if (showNotification && !_this.firstCICheck) { status = _this.ciLabelForStatus(data.status); if (status === "preparing") { @@ -142,6 +183,41 @@ })(this)); }; + MergeRequestWidget.prototype.pollCIEnvironmentsStatus = function() { + this.fetchBuildEnvironmentStatusInterval = setInterval(() => { + if (!this.readyForCIEnvironmentCheck) return; + this.getCIEnvironmentsStatus(); + this.readyForCIEnvironmentCheck = false; + }, 300000); + }; + + MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() { + $.getJSON(this.opts.ci_environments_status_url, (environments) => { + if (this.cancel) return; + this.readyForCIEnvironmentCheck = true; + if (environments && environments.length) this.renderEnvironments(environments); + }); + }; + + MergeRequestWidget.prototype.renderEnvironments = function(environments) { + for (let i = 0; i < environments.length; i++) { + const environment = environments[i]; + if ($(`.mr-state-widget #${ environment.id }`).length) return; + const $template = $(DEPLOYMENT_TEMPLATE); + if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove(); + if (environment.deployed_at && environment.deployed_at_formatted) { + environment.deployed_at = $.timeago(environment.deployed_at) + '.'; + } else { + $('.js-environment-timeago', $template).remove(); + environment.name += '.'; + } + environment.ci_success_icon = this.$ciSuccessIcon; + const templateString = _.unescape($template[0].outerHTML); + const template = _.template(templateString)(environment) + this.$widgetBody.before(template); + } + }; + MergeRequestWidget.prototype.showCIStatus = function(state) { var allowed_states; if (state == null) { @@ -182,4 +258,4 @@ })(); -}).call(this); + })(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index e8d51da7d58..bc1a99057d9 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -110,6 +110,7 @@ }, update: function(event, ui) { var data; + // Prevents sorting from container which element has been removed. if ($(this).find(ui.item).length > 0) { data = $(this).sortable("serialize"); return Milestone.sortIssues(data); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index a0b65d20c03..cee42633c79 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -7,7 +7,7 @@ this.currentProject = JSON.parse(currentProject); } $('.js-milestone-select').each(function(i, dropdown) { - var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId; + var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId, showMenuAbove; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); milestonesUrl = $dropdown.data('milestones'); @@ -15,6 +15,7 @@ selectedMilestone = $dropdown.data('selected'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); + showMenuAbove = $dropdown.data('showMenuAbove'); showUpcoming = $dropdown.data('show-upcoming'); useId = $dropdown.data('use-id'); defaultLabel = $dropdown.data('default-label'); @@ -31,12 +32,12 @@ collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- remaining %>" data-placement="left"> <%- title %> </span>'); } return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, data: function(term, callback) { return $.ajax({ url: milestonesUrl }).done(function(data) { - var extraOptions; - extraOptions = []; + var extraOptions = []; if (showAny) { extraOptions.push({ id: 0, @@ -58,10 +59,14 @@ title: 'Upcoming' }); } - if (extraOptions.length > 2) { + if (extraOptions.length) { extraOptions.push('divider'); } - return callback(extraOptions.concat(data)); + + callback(extraOptions.concat(data)); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }); }, filterable: true, @@ -69,19 +74,20 @@ fields: ['title'] }, selectable: true, - toggleLabel: function(selected) { - if (selected && 'id' in selected) { + toggleLabel: function(selected, el, e) { + if (selected && 'id' in selected && $(el).hasClass('is-active')) { return selected.title; } else { return defaultLabel; } }, + defaultLabel: defaultLabel, fieldName: $dropdown.data('field-name'), text: function(milestone) { return _.escape(milestone.title); }, id: function(milestone) { - if (!useId) { + if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { return milestone.name; } else { return milestone.id; @@ -92,17 +98,23 @@ }, hidden: function() { $selectbox.hide(); + // display:block overrides the hide-collapse rule return $value.css('display', ''); }, - clicked: function(selected) { + clicked: function(selected, $el, e) { var data, isIssueIndex, isMRIndex, page; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); - if ($dropdown.hasClass('js-filter-bulk-update')) { + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); return; } - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if ($('html').hasClass('issue-boards-page')) { + gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name; + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (selected.name != null) { selectedMilestone = selected.name; } else { diff --git a/app/assets/javascripts/network/branch-graph.js b/app/assets/javascripts/network/branch_graph.js index c0fec1f8607..91132af273a 100644 --- a/app/assets/javascripts/network/branch-graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -90,6 +90,7 @@ results = []; while (k < this.mspace) { this.colors.push(Raphael.getColor(.8)); + // Skipping a few colors in the spectrum to get more contrast between colors Raphael.getColor(); Raphael.getColor(); results.push(k++); @@ -112,6 +113,7 @@ for (mm = j = 0, len = ref.length; j < len; mm = ++j) { day = ref[mm]; if (cuday !== day[0] || cumonth !== day[1]) { + // Dates r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ font: "12px Monaco, monospace", fill: "#BBB" @@ -119,6 +121,7 @@ cuday = day[0]; } if (cumonth !== day[1]) { + // Months r.text(20, this.offsetY + this.unitTime * mm, day[1]).attr({ font: "12px Monaco, monospace", fill: "#EEE" @@ -207,6 +210,7 @@ } r = this.r; shortrefs = commit.refs; + // Truncate if longer than 15 chars if (shortrefs.length > 17) { shortrefs = shortrefs.substr(0, 15) + "…"; } @@ -217,6 +221,7 @@ title: commit.refs }); textbox = text.getBBox(); + // Create rectangle based on the size of the textbox rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ fill: "#000", "fill-opacity": .5, @@ -229,6 +234,7 @@ }); label = r.set(rect, text); label.transform(["t", -rect.getBBox().width - 15, 0]); + // Set text to front return text.toFront(); }; @@ -283,11 +289,13 @@ parentY = this.offsetY + this.unitTime * parentCommit.time; parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); + // Set line color if (parentCommit.space <= commit.space) { color = this.colors[commit.space]; } else { color = this.colors[parentCommit.space]; } + // Build line shape if (parent[1] === commit.space) { offset = [0, 5]; arrow = "l-2,5,4,0,-2,-5,0,5"; @@ -298,13 +306,17 @@ offset = [-3, 3]; arrow = "l-5,0,2,4,3,-4,-4,2"; } + // Start point route = ["M", x + offset[0], y + offset[1]]; + // Add arrow if not first parent if (i > 0) { route.push(arrow); } + // Circumvent if overlap if (commit.space !== parentCommit.space || commit.space !== parent[1]) { route.push("L", parentX2, y + 10, "L", parentX2, parentY - 5); } + // End point route.push("L", parentX1, parentY); results.push(r.path(route).attr({ stroke: color, @@ -325,6 +337,7 @@ "fill-opacity": .5, stroke: "none" }); + // Displayed in the center return this.element.scrollTop(y - this.graphHeight / 2); } }; diff --git a/app/assets/javascripts/network/network_bundle.js b/app/assets/javascripts/network/network_bundle.js index 6a7422a7755..67c3e645364 100644 --- a/app/assets/javascripts/network/network_bundle.js +++ b/app/assets/javascripts/network/network_bundle.js @@ -1,4 +1,9 @@ - +// This is a manifest file that'll be compiled into including all the files listed below. +// Add new JavaScript/Coffee 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 . */ (function() { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 9ece474d994..866a04d3e21 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,22 +1,10 @@ /*= require autosave */ - - /*= require autosize */ - - /*= require dropzone */ - - /*= require dropzone_input */ - - /*= require gfm_auto_complete */ - - /*= require jquery.atwho */ - - /*= require task_list */ (function() { @@ -60,25 +48,43 @@ } Notes.prototype.addBinding = function() { + // add note to UI after creation $(document).on("ajax:success", ".js-main-target-form", this.addNote); $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); + // catch note ajax errors $(document).on("ajax:error", ".js-main-target-form", this.addNoteError); + // change note in UI after update $(document).on("ajax:success", "form.edit-note", this.updateNote); + // Edit note link $(document).on("click", ".js-note-edit", this.showEditForm); $(document).on("click", ".note-edit-cancel", this.cancelEdit); + // Reopen and close actions for Issue/MR combined with note form submit $(document).on("click", ".js-comment-button", this.updateCloseButton); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); + // resolve a discussion + $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion); + // remove a note (in general) $(document).on("click", ".js-note-delete", this.removeNote); + // delete note attachment $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); + // reset main target form after submit $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); + // reset main target form when clicking discard $(document).on("click", ".js-note-discard", this.resetMainTargetForm); + // update the file name when an attachment is selected $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment); + // reply to diff/discussion notes $(document).on("click", ".js-discussion-reply-button", this.replyToDiscussionNote); + // add diff note $(document).on("click", ".js-add-diff-note-button", this.addDiffNote); + // hide diff note form $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); + // fetch notes when tab becomes visible $(document).on("visibilitychange", this.visibilityChange); + // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); + // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; @@ -100,6 +106,7 @@ $(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-discard"); $(document).off("keydown", ".js-note-text"); + $(document).off('click', '.js-comment-resolve-button'); $('.note .js-task-list-container').taskList('disable'); return $(document).off('tasklist:changed', '.note .js-task-list-container'); }; @@ -110,6 +117,7 @@ return; } $textarea = $(e.target); + // Edit previous note when UP arrow is hit switch (e.which) { case 38: if ($textarea.val() !== '') { @@ -121,6 +129,7 @@ return myLastNoteEditBtn.trigger('click', [true, myLastNote]); } break; + // Cancel creating diff note or editing any note when ESCAPE is hit case 27: discussionNoteForm = $textarea.closest('.js-discussion-note-form'); if (discussionNoteForm.length) { @@ -201,7 +210,7 @@ Increase @pollingInterval up to 120 seconds on every function call, if `shouldReset` has a truthy value, 'null' or 'undefined' the variable will reset to @basePollingInterval. - + Note: this function is used to gradually increase the polling interval if there aren't new notes coming from the server */ @@ -223,7 +232,7 @@ /* Render note in main comments area. - + Note: for rendering inline notes use renderDiscussionNote */ @@ -231,7 +240,13 @@ var $notesList, votesBlock; if (!note.valid) { if (note.award) { - new Flash('You have already awarded this emoji!', 'alert'); + new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline); + } + else { + if (note.errors.commands_only) { + new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + this.refresh(); + } } return; } @@ -239,12 +254,16 @@ votesBlock = $('.js-awards-block').eq(0); gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.name); return gl.awardsHandler.scrollToAwards(); + // render note if it not present in loaded list + // or skip if rendered } else if (this.isNewNote(note)) { this.note_ids.push(note.id); $notesList = $('ul.main-notes-list'); $notesList.append(note.html).syntaxHighlight(); + // Update datetime format on the recent note gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); this.initTaskList(); + this.refresh(); return this.updateNotesCount(1); } }; @@ -265,7 +284,7 @@ /* Render note in discussion area. - + Note: for rendering inline notes use renderDiscussionNote */ @@ -282,21 +301,33 @@ row = form.closest("tr"); note_html = $(note.html); note_html.syntaxHighlight(); + // is this the first note of discussion? discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); if ((note.original_discussion_id != null) && discussionContainer.length === 0) { discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']"); } if (discussionContainer.length === 0) { + // insert the note and the reply button after the temp row row.after(note.diff_discussion_html); + // remove the note (will be added again below) row.next().find(".note").remove(); + // Before that, the container didn't exist discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); + // Add note to 'Changes' page discussions discussionContainer.append(note_html); + // Init discussion on 'Discussion' page if it is merge request page if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { $('ul.main-notes-list').append(note.discussion_html).syntaxHighlight(); } } else { + // append new note to all matching discussions discussionContainer.append(note_html); } + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } + gl.utils.localTimeAgo($('.js-timeago', note_html), false); return this.updateNotesCount(1); }; @@ -304,7 +335,7 @@ /* Called in response the main target form has been successfully submitted. - + Removes any errors. Resets text and preview. Resets buttons. @@ -313,11 +344,18 @@ Notes.prototype.resetMainTargetForm = function(e) { var form; form = $(".js-main-target-form"); + // remove validation errors form.find(".js-errors").remove(); + // reset text and preview form.find(".js-md-write-button").click(); form.find(".js-note-text").val("").trigger("input"); form.find(".js-note-text").data("autosave").reset(); - return this.updateTargetButtons(e); + + var event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + form.find('.js-autosize')[0].dispatchEvent(event); + + this.updateTargetButtons(e); }; Notes.prototype.reenableTargetFormSubmitButton = function() { @@ -329,27 +367,32 @@ /* Shows the main form and does some setup on it. - + Sets some hidden fields in the form. */ Notes.prototype.setupMainTargetNoteForm = function() { var form; + // find the form form = $(".js-new-note-form"); + // Set a global clone of the form for later cloning this.formClone = form.clone(); + // show the form this.setupNoteForm(form); + // fix classes form.removeClass("js-new-note-form"); form.addClass("js-main-target-form"); form.find("#note_line_code").remove(); form.find("#note_position").remove(); form.find("#note_type").remove(); + form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); return this.parentTimeline = form.parents('.timeline'); }; /* General note form setup. - + deactivates the submit button when text is empty hides the preview button when text is empty setup GFM auto complete @@ -366,7 +409,7 @@ /* Called in response to the new note form being submitted - + Adds new note to list. */ @@ -381,36 +424,56 @@ /* Called in response to the new note form being submitted - + Adds new note to list. */ Notes.prototype.addDiscussionNote = function(xhr, note, status) { + var $form = $(xhr.target); + + if ($form.attr('data-resolve-all') != null) { + var projectPath = $form.data('project-path') + discussionId = $form.data('discussion-id'), + mergeRequestId = $form.data('noteable-iid'); + + if (ResolveService != null) { + ResolveService.toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId); + } + } + this.renderDiscussionNote(note); - return this.removeDiscussionNoteForm($(xhr.target)); + // cleanup after successfully creating a diff/discussion note + this.removeDiscussionNoteForm($form); }; /* Called in response to the edit note form being submitted - + Updates the current note field. */ Notes.prototype.updateNote = function(_xhr, note, _status) { var $html, $note_li; + // Convert returned HTML to a jQuery object so we can modify it further $html = $(note.html); gl.utils.localTimeAgo($('.js-timeago', $html)); $html.syntaxHighlight(); $html.find('.js-task-list-container').taskList('enable'); + // Find the note's `li` element by ID and replace it with the updated HTML $note_li = $('.note-row-' + note.id); - return $note_li.replaceWith($html); + + $note_li.replaceWith($html); + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } }; /* Called in response to clicking the edit note link - + Replaces the note text with the note edit form Adds a data attribute to the form with the original content of the note for cancellations */ @@ -422,15 +485,20 @@ note.addClass("is-editting"); form = note.find(".note-edit-form"); form.addClass('current-note-edit-form'); + // Show the attachment delete link note.find(".js-note-attachment-delete").show(); done = function($noteText) { var noteTextVal; + // Neat little trick to put the cursor at the end noteTextVal = $noteText.val(); + // Store the original note text in a data attribute to retrieve if a user cancels edit. form.find('form.edit-note').data('original-note', noteTextVal); return $noteText.val('').val(noteTextVal); }; new GLForm(form); if ((scrollTo != null) && (myLastNote != null)) { + // scroll to the bottom + // so the open of the last element doesn't make a jump $('html, body').scrollTop($(document).height()); return $('html, body').animate({ scrollTop: myLastNote.offset().top - 150 @@ -450,7 +518,7 @@ /* Called in response to clicking the edit note link - + Hides edit form and restores the original note text to the editor textarea. */ @@ -466,13 +534,14 @@ form = note.find(".current-note-edit-form"); note.removeClass("is-editting"); form.removeClass("current-note-edit-form"); + // Replace markdown textarea text with original note text. return form.find(".js-note-text").val(form.find('form.edit-note').data('original-note')); }; /* Called in response to deleting a note of any kind. - + Removes the actual note from view. Removes the whole discussion if the last note is being removed. */ @@ -481,24 +550,40 @@ var noteId; noteId = $(e.currentTarget).closest(".note").attr("id"); $(".note[id='" + noteId + "']").each((function(_this) { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // to remove all. Using $(".note[id='noteId']") ensure we get all the notes, + // where $("#noteId") would return only one. return function(i, el) { var note, notes; note = $(el); notes = note.closest(".notes"); + + if (typeof DiffNotesApp !== "undefined" && DiffNotesApp !== null) { + ref = DiffNotesApp.$refs[noteId]; + + if (ref) { + ref.$destroy(true); + } + } + + // check if this is the last note for this line if (notes.find(".note").length === 1) { + // "Discussions" tab notes.closest(".timeline-entry").remove(); + // "Changes" tab / commit view notes.closest("tr").remove(); } return note.remove(); }; })(this)); + // Decrement the "Discussions" counter only once return this.updateNotesCount(-1); }; /* Called in response to clicking the delete attachment link - + Removes the attachment wrapper view, including image tag if it exists Resets the note editing form */ @@ -515,7 +600,7 @@ /* Called when clicking on the "reply" button for a diff line. - + Shows the note form below the notes. */ @@ -523,22 +608,27 @@ var form, replyLink; form = this.formClone.clone(); replyLink = $(e.target).closest(".js-discussion-reply-button"); - replyLink.hide(); - replyLink.after(form); + // insert the form after the button + replyLink + .closest('.discussion-reply-holder') + .hide() + .after(form); + // show the form return this.setupDiscussionNoteForm(replyLink, form); }; /* Shows the diff or discussion form and does some setup on it. - + Sets some hidden fields in the form. - + Note: dataHolder must have the "discussionId", "lineCode", "noteableType" and "noteableId" data attributes set. */ Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { + // setup note target form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId"))); form.attr("data-line-code", dataHolder.data("lineCode")); form.find("#note_type").val(dataHolder.data("noteType")); @@ -549,15 +639,29 @@ form.find("#note_noteable_type").val(dataHolder.data("noteableType")); form.find("#note_noteable_id").val(dataHolder.data("noteableId")); form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); + form.find('.js-note-target-close').remove(); this.setupNoteForm(form); + + if (typeof DiffNotesApp !== 'undefined') { + var $commentBtn = form.find('comment-and-resolve-btn'); + $commentBtn + .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'"); + DiffNotesApp.$compile($commentBtn.get(0)); + } + form.find(".js-note-text").focus(); - return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form"); + form + .find('.js-comment-resolve-button') + .attr('data-discussion-id', dataHolder.data('discussionId')); + form + .removeClass('js-main-target-form') + .addClass("discussion-form js-discussion-note-form"); }; /* Called when clicking on the "add a comment" button on the side of a diff line. - + Inserts a temporary row for the form below the line. Sets up the form and shows it. */ @@ -570,21 +674,26 @@ nextRow = row.next(); hasNotes = nextRow.is(".notes_holder"); addForm = false; - targetContent = ".notes_content"; - rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>"; + notesContentSelector = ".notes_content"; + rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>"; + // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { lineType = $link.data("lineType"); - targetContent += "." + lineType; - rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>"; + notesContentSelector += "." + lineType; + rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>"; } + notesContentSelector += " .content"; if (hasNotes) { - notesContent = nextRow.find(targetContent); + nextRow.show(); + notesContent = nextRow.find(notesContentSelector); if (notesContent.length) { + notesContent.show(); replyButton = notesContent.find(".js-discussion-reply-button:visible"); if (replyButton.length) { e.target = replyButton[0]; $.proxy(this.replyToDiscussionNote, replyButton[0], e).call(); } else { + // In parallel view, the form may not be present in one of the panes noteForm = notesContent.find(".js-discussion-note-form"); if (noteForm.length === 0) { addForm = true; @@ -592,12 +701,16 @@ } } } else { + // add a notes row and insert the form row.after(rowCssToAdd); + nextRow = row.next(); + notesContent = nextRow.find(notesContentSelector); addForm = true; } if (addForm) { newForm = this.formClone.clone(); - newForm.appendTo(row.next().find(targetContent)); + newForm.appendTo(notesContent); + // show the form return this.setupDiscussionNoteForm($link, newForm); } }; @@ -605,7 +718,7 @@ /* Called in response to "cancel" on a diff note form. - + Shows the reply button again. Removes the form and if necessary it's temporary row. */ @@ -616,10 +729,15 @@ glForm = form.data('gl-form'); glForm.destroy(); form.find(".js-note-text").data("autosave").reset(); - form.prev(".js-discussion-reply-button").show(); + // show the reply button (will only work for replies) + form + .prev('.discussion-reply-holder') + .show(); if (row.is(".js-temp-notes-holder")) { + // remove temporary row for diff lines return row.remove(); } else { + // only remove the form return form.remove(); } }; @@ -634,13 +752,14 @@ /* Called after an attachment file has been selected. - + Updates the file name for the selected attachment. */ Notes.prototype.updateFormAttachment = function() { var filename, form; form = $(this).closest("form"); + // get only the basename filename = $(this).val().replace(/^.*[\\\/]/, ""); return form.find(".js-attachment-filename").text(filename); }; @@ -725,6 +844,17 @@ return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount); }; + Notes.prototype.resolveDiscussion = function () { + var $this = $(this), + discussionId = $this.attr('data-discussion-id'); + + $this + .closest('form') + .attr('data-discussion-id', discussionId) + .attr('data-resolve-all', 'true') + .attr('data-project-path', $this.attr('data-project-path')); + }; + return Notes; })(); diff --git a/app/assets/javascripts/pipeline.js.es6 b/app/assets/javascripts/pipeline.js.es6 new file mode 100644 index 00000000000..6bf63ee6979 --- /dev/null +++ b/app/assets/javascripts/pipeline.js.es6 @@ -0,0 +1,40 @@ +((global) => { + + class Pipelines { + constructor() { + $(document).off('click', '.toggle-pipeline-btn').on('click', '.toggle-pipeline-btn', this.toggleGraph); + this.addMarginToBuildColumns(); + } + + toggleGraph() { + const $pipelineBtn = $(this).closest('.toggle-pipeline-btn'); + const $pipelineGraph = $(this).closest('.row-content-block').next('.pipeline-graph'); + const $btnText = $(this).find('.toggle-btn-text'); + const graphCollapsed = $pipelineGraph.hasClass('graph-collapsed'); + + $($pipelineBtn).add($pipelineGraph).toggleClass('graph-collapsed'); + + + graphCollapsed ? $btnText.text('Expand') : $btnText.text('Hide') + } + + addMarginToBuildColumns() { + const $secondChildBuildNode = $('.build:nth-child(2)'); + if ($secondChildBuildNode.length) { + const $firstChildBuildNode = $secondChildBuildNode.prev('.build'); + const $multiBuildColumn = $secondChildBuildNode.closest('.stage-column'); + const $previousColumn = $multiBuildColumn.prev('.stage-column'); + $multiBuildColumn.addClass('left-margin'); + $firstChildBuildNode.addClass('left-connector'); + $previousColumn.each(function() { + $this = $(this); + if ($('.build', $this).length === 1) $this.addClass('no-margin'); + }); + } + $('.pipeline-graph').removeClass('hidden'); + } + } + + global.Pipelines = Pipelines; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/markdown_preview.js b/app/assets/javascripts/preview_markdown.js index 18fc7bae09a..5200487814f 100644 --- a/app/assets/javascripts/markdown_preview.js +++ b/app/assets/javascripts/preview_markdown.js @@ -1,9 +1,15 @@ +// MarkdownPreview +// +// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview, +// and showing a warning when more than `x` users are referenced. +// (function() { var lastTextareaPreviewed, markdownPreview, previewButtonSelector, writeButtonSelector; this.MarkdownPreview = (function() { function MarkdownPreview() {} + // Minimum number of users referenced before triggering a warning MarkdownPreview.prototype.referenceThreshold = 10; MarkdownPreview.prototype.ajaxCache = {}; @@ -28,7 +34,7 @@ }; MarkdownPreview.prototype.renderMarkdown = function(text, success) { - if (!window.markdown_preview_path) { + if (!window.preview_markdown_path) { return; } if (text === this.ajaxCache.text) { @@ -36,7 +42,7 @@ } return $.ajax({ type: 'POST', - url: window.markdown_preview_path, + url: window.preview_markdown_path, data: { text: text }, @@ -101,8 +107,10 @@ return; } lastTextareaPreviewed = $form.find('textarea.markdown-area'); + // toggle tabs $form.find(writeButtonSelector).parent().removeClass('active'); $form.find(previewButtonSelector).parent().addClass('active'); + // toggle content $form.find('.md-write-holder').hide(); $form.find('.md-preview-holder').show(); return markdownPreview.showPreview($form); @@ -113,8 +121,10 @@ return; } lastTextareaPreviewed = null; + // toggle tabs $form.find(writeButtonSelector).parent().addClass('active'); $form.find(previewButtonSelector).parent().removeClass('active'); + // toggle content $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); return $form.find('.md-preview-holder').hide(); diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js.es6 index a3eea316f67..a1b0126e857 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js.es6 @@ -1,39 +1,45 @@ -(function() { - var GitLabCrop, - bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; +((global) => { - GitLabCrop = (function() { - var FILENAMEREGEX; + // Matches everything but the file name + const FILENAMEREGEX = /^.*[\\\/]/; - FILENAMEREGEX = /^.*[\\\/]/; + class GitLabCrop { + constructor(input, { filename, previewImage, modalCrop, pickImageEl, uploadImageBtn, modalCropImg, + exportWidth = 200, exportHeight = 200, cropBoxWidth = 200, cropBoxHeight = 200 } = {}) { - function GitLabCrop(input, opts) { - var ref, ref1, ref2, ref3, ref4; - if (opts == null) { - opts = {}; - } - this.onUploadImageBtnClick = bind(this.onUploadImageBtnClick, this); - this.onModalHide = bind(this.onModalHide, this); - this.onModalShow = bind(this.onModalShow, this); - this.onPickImageClick = bind(this.onPickImageClick, this); + this.onUploadImageBtnClick = this.onUploadImageBtnClick.bind(this); + this.onModalHide = this.onModalHide.bind(this); + this.onModalShow = this.onModalShow.bind(this); + this.onPickImageClick = this.onPickImageClick.bind(this); this.fileInput = $(input); - this.fileInput.attr('name', (this.fileInput.attr('name')) + "-trigger").attr('id', (this.fileInput.attr('id')) + "-trigger"); - this.exportWidth = (ref = opts.exportWidth) != null ? ref : 200, this.exportHeight = (ref1 = opts.exportHeight) != null ? ref1 : 200, this.cropBoxWidth = (ref2 = opts.cropBoxWidth) != null ? ref2 : 200, this.cropBoxHeight = (ref3 = opts.cropBoxHeight) != null ? ref3 : 200, this.form = (ref4 = opts.form) != null ? ref4 : this.fileInput.parents('form'), this.filename = opts.filename, this.previewImage = opts.previewImage, this.modalCrop = opts.modalCrop, this.pickImageEl = opts.pickImageEl, this.uploadImageBtn = opts.uploadImageBtn, this.modalCropImg = opts.modalCropImg; - this.filename = this.getElement(this.filename); - this.previewImage = this.getElement(this.previewImage); - this.pickImageEl = this.getElement(this.pickImageEl); - this.modalCrop = _.isString(this.modalCrop) ? $(this.modalCrop) : this.modalCrop; - this.uploadImageBtn = _.isString(this.uploadImageBtn) ? $(this.uploadImageBtn) : this.uploadImageBtn; this.modalCropImg = _.isString(this.modalCropImg) ? $(this.modalCropImg) : this.modalCropImg; + this.fileInput.attr('name', `${this.fileInput.attr('name')}-trigger`).attr('id', `this.fileInput.attr('id')-trigger`); + this.exportWidth = exportWidth; + this.exportHeight = exportHeight; + this.cropBoxWidth = cropBoxWidth; + this.cropBoxHeight = cropBoxHeight; + this.form = this.fileInput.parents('form'); + this.filename = filename; + this.previewImage = previewImage; + this.modalCrop = modalCrop; + this.pickImageEl = pickImageEl; + this.uploadImageBtn = uploadImageBtn; + this.modalCropImg = modalCropImg; + this.filename = this.getElement(filename); + this.previewImage = this.getElement(previewImage); + this.pickImageEl = this.getElement(pickImageEl); + this.modalCrop = _.isString(modalCrop) ? $(modalCrop) : modalCrop; + this.uploadImageBtn = _.isString(uploadImageBtn) ? $(uploadImageBtn) : uploadImageBtn; + this.modalCropImg = _.isString(modalCropImg) ? $(modalCropImg) : modalCropImg; this.cropActionsBtn = this.modalCrop.find('[data-method]'); this.bindEvents(); } - GitLabCrop.prototype.getElement = function(selector) { + getElement(selector) { return $(selector, this.form); - }; + } - GitLabCrop.prototype.bindEvents = function() { + bindEvents() { var _this; _this = this; this.fileInput.on('change', function(e) { @@ -49,13 +55,13 @@ return _this.onActionBtnClick(btn); }); return this.croppedImageBlob = null; - }; + } - GitLabCrop.prototype.onPickImageClick = function() { + onPickImageClick() { return this.fileInput.trigger('click'); - }; + } - GitLabCrop.prototype.onModalShow = function() { + onModalShow() { var _this; _this = this; return this.modalCropImg.cropper({ @@ -87,44 +93,44 @@ }); } }); - }; + } - GitLabCrop.prototype.onModalHide = function() { + onModalHide() { return this.modalCropImg.attr('src', '').cropper('destroy'); - }; + } - GitLabCrop.prototype.onUploadImageBtnClick = function(e) { + onUploadImageBtnClick(e) { e.preventDefault(); this.setBlob(); this.setPreview(); this.modalCrop.modal('hide'); return this.fileInput.val(''); - }; + } - GitLabCrop.prototype.onActionBtnClick = function(btn) { + onActionBtnClick(btn) { var data, result; data = $(btn).data(); if (this.modalCropImg.data('cropper') && data.method) { return result = this.modalCropImg.cropper(data.method, data.option); } - }; + } - GitLabCrop.prototype.onFileInputChange = function(e, input) { + onFileInputChange(e, input) { return this.readFile(input); - }; + } - GitLabCrop.prototype.readFile = function(input) { + readFile(input) { var _this, reader; _this = this; reader = new FileReader; - reader.onload = function() { + reader.onload = () => { _this.modalCropImg.attr('src', reader.result); return _this.modalCrop.modal('show'); }; return reader.readAsDataURL(input.files[0]); - }; + } - GitLabCrop.prototype.dataURLtoBlob = function(dataURL) { + dataURLtoBlob(dataURL) { var array, binary, i, k, len, v; binary = atob(dataURL.split(',')[1]); array = []; @@ -135,35 +141,32 @@ return new Blob([new Uint8Array(array)], { type: 'image/png' }); - }; + } - GitLabCrop.prototype.setPreview = function() { + setPreview() { var filename; this.previewImage.attr('src', this.dataURL); filename = this.fileInput.val().replace(FILENAMEREGEX, ''); return this.filename.text(filename); - }; + } - GitLabCrop.prototype.setBlob = function() { + setBlob() { this.dataURL = this.modalCropImg.cropper('getCroppedCanvas', { width: 200, height: 200 }).toDataURL('image/png'); return this.croppedImageBlob = this.dataURLtoBlob(this.dataURL); - }; + } - GitLabCrop.prototype.getBlob = function() { + getBlob() { return this.croppedImageBlob; - }; - - return GitLabCrop; - - })(); + } + } $.fn.glCrop = function(opts) { return this.each(function() { return $(this).data('glcrop', new GitLabCrop(this, opts)); }); - }; + } -}).call(this); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js deleted file mode 100644 index ed1d87abafe..00000000000 --- a/app/assets/javascripts/profile/profile.js +++ /dev/null @@ -1,102 +0,0 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - - this.Profile = (function() { - function Profile(opts) { - var cropOpts, ref; - if (opts == null) { - opts = {}; - } - this.onSubmitForm = bind(this.onSubmitForm, this); - this.form = (ref = opts.form) != null ? ref : $('.edit-user'); - $('.js-preferences-form').on('change.preference', 'input[type=radio]', function() { - return $(this).parents('form').submit(); - }); - $('#user_notification_email').on('change', function() { - return $(this).parents('form').submit(); - }); - $('.update-username').on('ajax:before', function() { - $('.loading-username').show(); - $(this).find('.update-success').hide(); - return $(this).find('.update-failed').hide(); - }); - $('.update-username').on('ajax:complete', function() { - $('.loading-username').hide(); - $(this).find('.btn-save').enable(); - return $(this).find('.loading-gif').hide(); - }); - $('.update-notifications').on('ajax:success', function(e, data) { - if (data.saved) { - return new Flash("Notification settings saved", "notice"); - } else { - return new Flash("Failed to save new settings", "alert"); - } - }); - this.bindEvents(); - cropOpts = { - filename: '.js-avatar-filename', - previewImage: '.avatar-image .avatar', - modalCrop: '.modal-profile-crop', - pickImageEl: '.js-choose-user-avatar-button', - uploadImageBtn: '.js-upload-user-avatar', - modalCropImg: '.modal-profile-crop-image' - }; - this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); - } - - Profile.prototype.bindEvents = function() { - return this.form.on('submit', this.onSubmitForm); - }; - - Profile.prototype.onSubmitForm = function(e) { - e.preventDefault(); - return this.saveForm(); - }; - - Profile.prototype.saveForm = function() { - var avatarBlob, formData, self; - self = this; - formData = new FormData(this.form[0]); - avatarBlob = this.avatarGlCrop.getBlob(); - if (avatarBlob != null) { - formData.append('user[avatar]', avatarBlob, 'avatar.png'); - } - return $.ajax({ - url: this.form.attr('action'), - type: this.form.attr('method'), - data: formData, - dataType: "json", - processData: false, - contentType: false, - success: function(response) { - return new Flash(response.message, 'notice'); - }, - error: function(jqXHR) { - return new Flash(jqXHR.responseJSON.message, 'alert'); - }, - complete: function() { - window.scrollTo(0, 0); - return self.form.find(':input[disabled]').enable(); - } - }); - }; - - return Profile; - - })(); - - $(function() { - $(document).on('focusout.ssh_key', '#key_key', function() { - var $title, comment; - $title = $('#key_title'); - comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); - if (comment && comment.length > 1 && $title.val() === '') { - return $title.val(comment[1]).change(); - } - }); - if (gl.utils.getPagePath() === 'profiles') { - return new Profile(); - } - }); - -}).call(this); diff --git a/app/assets/javascripts/profile/profile.js.es6 b/app/assets/javascripts/profile/profile.js.es6 new file mode 100644 index 00000000000..b2307be73ad --- /dev/null +++ b/app/assets/javascripts/profile/profile.js.es6 @@ -0,0 +1,100 @@ +((global) => { + + class Profile { + constructor({ form } = {}) { + this.onSubmitForm = this.onSubmitForm.bind(this); + this.form = form || $('.edit-user'); + this.bindEvents(); + this.initAvatarGlCrop(); + } + + initAvatarGlCrop() { + const cropOpts = { + filename: '.js-avatar-filename', + previewImage: '.avatar-image .avatar', + modalCrop: '.modal-profile-crop', + pickImageEl: '.js-choose-user-avatar-button', + uploadImageBtn: '.js-upload-user-avatar', + modalCropImg: '.modal-profile-crop-image' + }; + this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); + } + + bindEvents() { + $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); + $('#user_notification_email').on('change', this.submitForm); + $('.update-username').on('ajax:before', this.beforeUpdateUsername); + $('.update-username').on('ajax:complete', this.afterUpdateUsername); + $('.update-notifications').on('ajax:success', this.onUpdateNotifs); + this.form.on('submit', this.onSubmitForm); + } + + submitForm() { + return $(this).parents('form').submit(); + } + + onSubmitForm(e) { + e.preventDefault(); + return this.saveForm(); + } + + beforeUpdateUsername() { + $('.loading-username').show(); + $(this).find('.update-success').hide(); + return $(this).find('.update-failed').hide(); + } + + afterUpdateUsername() { + $('.loading-username').hide(); + $(this).find('.btn-save').enable(); + return $(this).find('.loading-gif').hide(); + } + + onUpdateNotifs(e, data) { + return data.saved ? + new Flash("Notification settings saved", "notice") : + new Flash("Failed to save new settings", "alert"); + } + + saveForm() { + const self = this; + const formData = new FormData(this.form[0]); + const avatarBlob = this.avatarGlCrop.getBlob(); + + if (avatarBlob != null) { + formData.append('user[avatar]', avatarBlob, 'avatar.png'); + } + + return $.ajax({ + url: this.form.attr('action'), + type: this.form.attr('method'), + data: formData, + dataType: "json", + processData: false, + contentType: false, + success: response => new Flash(response.message, 'notice'), + error: jqXHR => new Flash(jqXHR.responseJSON.message, 'alert'), + complete: () => { + window.scrollTo(0, 0); + // Enable submit button after requests ends + return self.form.find(':input[disabled]').enable(); + } + }); + } + } + + $(function() { + $(document).on('focusout.ssh_key', '#key_key', function() { + const $title = $('#key_title'); + const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); + if (comment && comment.length > 1 && $title.val() === '') { + return $title.val(comment[1]).change(); + } + // Extract the SSH Key title from its comment + }); + if (global.utils.getPagePath() === 'profiles') { + return new Profile(); + } + }); + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js index b95faadc8e7..d6e4d9f7ad8 100644 --- a/app/assets/javascripts/profile/profile_bundle.js +++ b/app/assets/javascripts/profile/profile_bundle.js @@ -3,5 +3,4 @@ (function() { - }).call(this); diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index e6663177161..a6c015299a0 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -11,25 +11,27 @@ url = $("#project_clone").val(); $('#project_clone').val(url); return $('.clone').text(url); + // Git protocol switcher + // Remove the active class for all buttons (ssh, http, kerberos if shown) + // Add the active class for the clicked button + // Update the input field + // Update the command line instructions }); + // Ref switcher this.initRefSwitcher(); $('.project-refs-select').on('change', function() { return $(this).parents('form').submit(); }); $('.hide-no-ssh-message').on('click', function(e) { - var path; - path = '/'; $.cookie('hide_no_ssh_message', 'false', { - path: path + path: gon.relative_url_root || '/' }); $(this).parents('.no-ssh-key-message').remove(); return e.preventDefault(); }); $('.hide-no-password-message').on('click', function(e) { - var path; - path = '/'; $.cookie('hide_no_password_message', 'false', { - path: path + path: gon.relative_url_root || '/' }); $(this).parents('.no-password-message').remove(); return e.preventDefault(); @@ -65,7 +67,8 @@ url: $dropdown.data('refs-url'), data: { ref: $dropdown.data('ref') - } + }, + dataType: "json" }).done(function(refs) { return callback(refs); }); @@ -73,7 +76,7 @@ selectable: true, filterable: true, filterByText: true, - fieldName: 'ref', + fieldName: $dropdown.data('field-name'), renderRow: function(ref) { var link; if (ref.header != null) { @@ -89,8 +92,14 @@ toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(e) { - return $dropdown.closest('form').submit(); + clicked: function(selected, $el, e) { + e.preventDefault() + if ($('input[name="ref"]').length) { + var $form = $dropdown.closest('form'), + action = $form.attr('action'), + divider = action.indexOf('?') < 0 ? '?' : '&'; + Turbolinks.visit(action + '' + divider + '' + $form.serialize()); + } } }); }); diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 4925f0519f0..8e38ccf7e44 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -7,14 +7,16 @@ function ProjectFindFile(element1, options) { this.element = element1; this.options = options; - this.goToBlob = bind(this.goToBlob, this); this.goToTree = bind(this.goToTree, this); this.selectRowDown = bind(this.selectRowDown, this); this.selectRowUp = bind(this.selectRowUp, this); this.filePaths = {}; this.inputElement = this.element.find(".file-finder-input"); + // init event this.initEvent(); + // focus text input box this.inputElement.focus(); + // load file list this.load(this.options.url); } @@ -33,15 +35,6 @@ } }; })(this)); - return this.element.find(".tree-content-holder .tree-table").on("click", function(event) { - var path; - if (event.target.nodeName !== "A") { - path = this.element.find(".tree-item-file-name a", this).attr("href"); - if (path) { - return location.href = path; - } - } - }); }; ProjectFindFile.prototype.findFile = function() { @@ -49,8 +42,10 @@ searchText = this.inputElement.val(); result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths; return this.renderList(result, searchText); + // find file }; + // files pathes load ProjectFindFile.prototype.load = function(url) { return $.ajax({ url: url, @@ -67,6 +62,7 @@ }); }; + // render result ProjectFindFile.prototype.renderList = function(filePaths, searchText) { var blobItemUrl, filePath, html, i, j, len, matches, results; this.element.find(".tree-table > tbody").empty(); @@ -86,6 +82,7 @@ return results; }; + // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> ) highlighter = function(element, text, matches) { var highlightText, j, lastIndex, len, matchIndex, matchedChars, unmatched; lastIndex = 0; @@ -110,13 +107,15 @@ return element.append(document.createTextNode(text.substring(lastIndex))); }; + // make tbody row html ProjectFindFile.prototype.makeHtml = function(filePath, matches, blobItemUrl) { var $tr; - $tr = $("<tr class='tree-item'><td class='tree-item-file-name'><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'><a></a></span></td></tr>"); + $tr = $("<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>"); if (matches) { $tr.find("a").replaceWith(highlighter($tr.find("a"), filePath, matches).attr("href", blobItemUrl)); } else { - $tr.find("a").attr("href", blobItemUrl).text(filePath); + $tr.find("a").attr("href", blobItemUrl); + $tr.find(".str-truncated").text(filePath); } return $tr; }; @@ -155,14 +154,6 @@ return location.href = this.options.treeUrl; }; - ProjectFindFile.prototype.goToBlob = function() { - var path; - path = this.element.find(".tree-item.selected .tree-item-file-name a").attr("href"); - if (path) { - return location.href = path; - } - }; - return ProjectFindFile; })(); diff --git a/app/assets/javascripts/project_members.js b/app/assets/javascripts/project_members.js index f6a796b325a..78f7b48bc7d 100644 --- a/app/assets/javascripts/project_members.js +++ b/app/assets/javascripts/project_members.js @@ -5,9 +5,6 @@ return $(this).fadeOut(); }); } - return ProjectMembers; - })(); - }).call(this); diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index 798f15e40a0..3cf41505814 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -4,6 +4,10 @@ this.ProjectNew = (function() { function ProjectNew() { this.toggleSettings = bind(this.toggleSettings, this); + this.$selects = $('.features select').filter(function () { + return $(this).data('field'); + }); + $('.project-edit-container').on('ajax:before', (function(_this) { return function() { $('.project-edit-container').hide(); @@ -15,18 +19,24 @@ } ProjectNew.prototype.toggleSettings = function() { - this._showOrHide('#project_builds_enabled', '.builds-feature'); - return this._showOrHide('#project_merge_requests_enabled', '.merge-requests-feature'); + var self = this; + + this.$selects.each(function () { + var $select = $(this), + className = $select.data('field').replace(/_/g, '-') + .replace('access-level', 'feature'); + self._showOrHide($select, '.' + className); + }); }; ProjectNew.prototype.toggleSettingsOnclick = function() { - return $('#project_builds_enabled, #project_merge_requests_enabled').on('click', this.toggleSettings); + this.$selects.on('change', this.toggleSettings); }; ProjectNew.prototype._showOrHide = function(checkElement, container) { - var $container; - $container = $(container); - if ($(checkElement).prop('checked')) { + var $container = $(container); + + if ($(checkElement).val() !== '0') { return $container.show(); } else { return $container.hide(); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 20b147500cf..4239ed2f889 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -23,7 +23,7 @@ data = groups.concat(projects); return finalCallback(data); }; - return Api.groups(term, false, groupsCallback); + return Api.groups(term, false, false, groupsCallback); }; } else { projectsCallback = finalCallback; @@ -72,7 +72,7 @@ data = groups.concat(projects); return finalCallback(data); }; - return Api.groups(query.term, false, groupsCallback); + return Api.groups(query.term, false, false, groupsCallback); }; } else { projectsCallback = finalCallback; diff --git a/app/assets/javascripts/project_show.js b/app/assets/javascripts/project_show.js index 8ca4c427912..c8cfc9a9ba8 100644 --- a/app/assets/javascripts/project_show.js +++ b/app/assets/javascripts/project_show.js @@ -7,3 +7,5 @@ })(); }).call(this); + +// I kept class for future diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js index 4f415b05dbc..04fb49552e8 100644 --- a/app/assets/javascripts/projects_list.js +++ b/app/assets/javascripts/projects_list.js @@ -33,6 +33,7 @@ $('.projects-list-holder').replaceWith(data.html); return history.replaceState({ page: project_filter_url + // Change url so if user reload a page - search results are saved }, document.title, project_filter_url); }, dataType: "json" diff --git a/app/assets/javascripts/protected_branch_access_dropdown.js.es6 b/app/assets/javascripts/protected_branch_access_dropdown.js.es6 new file mode 100644 index 00000000000..7aeb5f92514 --- /dev/null +++ b/app/assets/javascripts/protected_branch_access_dropdown.js.es6 @@ -0,0 +1,28 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchAccessDropdown = class { + constructor(options) { + const { $dropdown, data, onSelect } = options; + + $dropdown.glDropdown({ + data: data, + selectable: true, + inputId: $dropdown.data('input-id'), + fieldName: $dropdown.data('field-name'), + toggleLabel(item, el) { + if (el.is('.is-active')) { + return item.text; + } else { + return 'Select'; + } + }, + clicked(item, $el, e) { + e.preventDefault(); + onSelect(); + } + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6 new file mode 100644 index 00000000000..46beca469b9 --- /dev/null +++ b/app/assets/javascripts/protected_branch_create.js.es6 @@ -0,0 +1,54 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchCreate = class { + constructor() { + this.$wrap = this.$form = $('#new_protected_branch'); + this.buildDropdowns(); + } + + buildDropdowns() { + const $allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); + const $allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + + // Cache callback + this.onSelectCallback = this.onSelect.bind(this); + + // Allowed to Merge dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: $allowedToMergeDropdown, + data: gon.merge_access_levels, + onSelect: this.onSelectCallback + }); + + // Allowed to Push dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: $allowedToPushDropdown, + data: gon.push_access_levels, + onSelect: this.onSelectCallback + }); + + // Select default + $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0); + $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0); + + // Protected branch dropdown + new ProtectedBranchDropdown({ + $dropdown: this.$wrap.find('.js-protected-branch-select'), + onSelect: this.onSelectCallback + }); + } + + // This will run after clicked callback + onSelect() { + + // Enable submit button + const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]'); + const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]'); + const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]'); + + this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length)); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_dropdown.js.es6 b/app/assets/javascripts/protected_branch_dropdown.js.es6 new file mode 100644 index 00000000000..983322cbecc --- /dev/null +++ b/app/assets/javascripts/protected_branch_dropdown.js.es6 @@ -0,0 +1,76 @@ +class ProtectedBranchDropdown { + constructor(options) { + this.onSelect = options.onSelect; + this.$dropdown = options.$dropdown; + this.$dropdownContainer = this.$dropdown.parent(); + this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); + this.$protectedBranch = this.$dropdownContainer.find('.create-new-protected-branch'); + + this.buildDropdown(); + this.bindEvents(); + + // Hide footer + this.$dropdownFooter.addClass('hidden'); + } + + buildDropdown() { + this.$dropdown.glDropdown({ + data: this.getProtectedBranches.bind(this), + filterable: true, + remote: false, + search: { + fields: ['title'] + }, + selectable: true, + toggleLabel(selected) { + return (selected && 'id' in selected) ? selected.title : 'Protected Branch'; + }, + fieldName: 'protected_branch[name]', + text(protectedBranch) { + return _.escape(protectedBranch.title); + }, + id(protectedBranch) { + return _.escape(protectedBranch.id); + }, + onFilter: this.toggleCreateNewButton.bind(this), + clicked: (item, $el, e) => { + e.preventDefault(); + this.onSelect(); + } + }); + } + + bindEvents() { + this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this)); + } + + onClickCreateWildcard() { + // Refresh the dropdown's data, which ends up calling `getProtectedBranches` + this.$dropdown.data('glDropdown').remote.execute(); + this.$dropdown.data('glDropdown').selectRowAtIndex(0); + } + + getProtectedBranches(term, callback) { + if (this.selectedBranch) { + callback(gon.open_branches.concat(this.selectedBranch)); + } else { + callback(gon.open_branches); + } + } + + toggleCreateNewButton(branchName) { + this.selectedBranch = { + title: branchName, + id: branchName, + text: branchName + }; + + if (branchName) { + this.$dropdownContainer + .find('.create-new-protected-branch code') + .text(branchName); + } + + this.$dropdownFooter.toggleClass('hidden', !branchName); + } +} diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6 new file mode 100644 index 00000000000..15a6dca2875 --- /dev/null +++ b/app/assets/javascripts/protected_branch_edit.js.es6 @@ -0,0 +1,65 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchEdit = class { + constructor(options) { + this.$wrap = options.$wrap; + this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); + this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + + this.buildDropdowns(); + } + + buildDropdowns() { + + // Allowed to merge dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: this.$allowedToMergeDropdown, + data: gon.merge_access_levels, + onSelect: this.onSelect.bind(this) + }); + + // Allowed to push dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: this.$allowedToPushDropdown, + data: gon.push_access_levels, + onSelect: this.onSelect.bind(this) + }); + } + + onSelect() { + const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`); + const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`); + + // Do not update if one dropdown has not selected any option + if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return; + + $.ajax({ + type: 'POST', + url: this.$wrap.data('url'), + dataType: 'json', + data: { + _method: 'PATCH', + protected_branch: { + merge_access_levels_attributes: [{ + id: this.$allowedToMergeDropdown.data('access-level-id'), + access_level: $allowedToMergeInput.val() + }], + push_access_levels_attributes: [{ + id: this.$allowedToPushDropdown.data('access-level-id'), + access_level: $allowedToPushInput.val() + }] + } + }, + success: () => { + this.$wrap.effect('highlight'); + }, + error() { + $.scrollTo(0); + new Flash('Failed to update branch!'); + } + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_edit_list.js.es6 b/app/assets/javascripts/protected_branch_edit_list.js.es6 new file mode 100644 index 00000000000..9ff0fd12c76 --- /dev/null +++ b/app/assets/javascripts/protected_branch_edit_list.js.es6 @@ -0,0 +1,17 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchEditList = class { + constructor() { + this.$wrap = $('.protected-branches-list'); + + // Build edit forms + this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => { + new gl.ProtectedBranchEdit({ + $wrap: $(el) + }); + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_select.js b/app/assets/javascripts/protected_branch_select.js deleted file mode 100644 index 3a47fc972dc..00000000000 --- a/app/assets/javascripts/protected_branch_select.js +++ /dev/null @@ -1,72 +0,0 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - - this.ProtectedBranchSelect = (function() { - function ProtectedBranchSelect(currentProject) { - this.toggleCreateNewButton = bind(this.toggleCreateNewButton, this); - this.getProtectedBranches = bind(this.getProtectedBranches, this); - $('.dropdown-footer').hide(); - this.dropdown = $('.js-protected-branch-select').glDropdown({ - data: this.getProtectedBranches, - filterable: true, - remote: false, - search: { - fields: ['title'] - }, - selectable: true, - toggleLabel: function(selected) { - if (selected && 'id' in selected) { - return selected.title; - } else { - return 'Protected Branch'; - } - }, - fieldName: 'protected_branch[name]', - text: function(protected_branch) { - return _.escape(protected_branch.title); - }, - id: function(protected_branch) { - return _.escape(protected_branch.id); - }, - onFilter: this.toggleCreateNewButton, - clicked: function() { - return $('.protect-branch-btn').attr('disabled', false); - } - }); - $('.create-new-protected-branch').on('click', (function(_this) { - return function(event) { - _this.dropdown.data('glDropdown').remote.execute(); - return _this.dropdown.data('glDropdown').selectRowAtIndex(event, 0); - }; - })(this)); - } - - ProtectedBranchSelect.prototype.getProtectedBranches = function(term, callback) { - if (this.selectedBranch) { - return callback(gon.open_branches.concat(this.selectedBranch)); - } else { - return callback(gon.open_branches); - } - }; - - ProtectedBranchSelect.prototype.toggleCreateNewButton = function(branchName) { - this.selectedBranch = { - title: branchName, - id: branchName, - text: branchName - }; - if (branchName === '') { - $('.protected-branch-select-footer-list').addClass('hidden'); - return $('.dropdown-footer').hide(); - } else { - $('.create-new-protected-branch').text("Create Protected Branch: " + branchName); - $('.protected-branch-select-footer-list').removeClass('hidden'); - return $('.dropdown-footer').show(); - } - }; - - return ProtectedBranchSelect; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/protected_branches_access_select.js.es6 b/app/assets/javascripts/protected_branches_access_select.js.es6 deleted file mode 100644 index e98312bbf37..00000000000 --- a/app/assets/javascripts/protected_branches_access_select.js.es6 +++ /dev/null @@ -1,63 +0,0 @@ -class ProtectedBranchesAccessSelect { - constructor(container, saveOnSelect, selectDefault) { - this.container = container; - this.saveOnSelect = saveOnSelect; - - this.container.find(".allowed-to-merge").each((i, element) => { - var fieldName = $(element).data('field-name'); - var dropdown = $(element).glDropdown({ - data: gon.merge_access_levels, - selectable: true, - fieldName: fieldName, - clicked: _.chain(this.onSelect).partial(element).bind(this).value() - }); - - if (selectDefault) { - dropdown.data('glDropdown').selectRowAtIndex(document.createEvent("Event"), 0); - } - }); - - - this.container.find(".allowed-to-push").each((i, element) => { - var fieldName = $(element).data('field-name'); - var dropdown = $(element).glDropdown({ - data: gon.push_access_levels, - selectable: true, - fieldName: fieldName, - clicked: _.chain(this.onSelect).partial(element).bind(this).value() - }); - - if (selectDefault) { - dropdown.data('glDropdown').selectRowAtIndex(document.createEvent("Event"), 0); - } - }); - } - - onSelect(dropdown, selected, element, e) { - $(dropdown).find('.dropdown-toggle-text').text(selected.text); - if (this.saveOnSelect) { - return $.ajax({ - type: "POST", - url: $(dropdown).data('url'), - dataType: "json", - data: { - _method: 'PATCH', - id: $(dropdown).data('id'), - protected_branch: { - ["" + ($(dropdown).data('type')) + "_attributes"]: { - "access_level": selected.id - } - } - }, - success: function() { - var row; - row = $(e.target); - return row.closest('tr').effect('highlight'); - }, - error: function() { - return new Flash("Failed to update branch!", "alert"); - } - }); - } - } -} diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index dc4d5113826..e3d5f413c77 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -30,7 +30,7 @@ } if (!triggered) { return $.cookie("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed'), { - path: '/' + path: gon.relative_url_root || '/' }); } }); diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js index d34346f862b..8074a94f33e 100644 --- a/app/assets/javascripts/search.js +++ b/app/assets/javascripts/search.js @@ -10,7 +10,7 @@ filterable: true, fieldName: 'group_id', data: function(term, callback) { - return Api.groups(term, null, function(data) { + return Api.groups(term, false, false, function(data) { data.unshift({ name: 'Any' }); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js.es6 index 990f6536eb2..b4c6226dc68 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js.es6 @@ -1,27 +1,21 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; +((global) => { - this.SearchAutocomplete = (function() { - var KEYCODE; + const KEYCODE = { + ESCAPE: 27, + BACKSPACE: 8, + ENTER: 13, + UP: 38, + DOWN: 40 + }; - KEYCODE = { - ESCAPE: 27, - BACKSPACE: 8, - ENTER: 13 - }; - - function SearchAutocomplete(opts) { - var ref, ref1, ref2, ref3, ref4; - if (opts == null) { - opts = {}; - } - this.onSearchInputBlur = bind(this.onSearchInputBlur, this); - this.onClearInputClick = bind(this.onClearInputClick, this); - this.onSearchInputFocus = bind(this.onSearchInputFocus, this); - this.onSearchInputClick = bind(this.onSearchInputClick, this); - this.onSearchInputKeyUp = bind(this.onSearchInputKeyUp, this); - this.onSearchInputKeyDown = bind(this.onSearchInputKeyDown, this); - this.wrap = (ref = opts.wrap) != null ? ref : $('.search'), this.optsEl = (ref1 = opts.optsEl) != null ? ref1 : this.wrap.find('.search-autocomplete-opts'), this.autocompletePath = (ref2 = opts.autocompletePath) != null ? ref2 : this.optsEl.data('autocomplete-path'), this.projectId = (ref3 = opts.projectId) != null ? ref3 : this.optsEl.data('autocomplete-project-id') || '', this.projectRef = (ref4 = opts.projectRef) != null ? ref4 : this.optsEl.data('autocomplete-project-ref') || ''; + class SearchAutocomplete { + constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) { + this.bindEventContext(); + this.wrap = wrap || $('.search'); + this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts'); + this.autocompletePath = autocompletePath || this.optsEl.data('autocomplete-path'); + this.projectId = projectId || (this.optsEl.data('autocomplete-project-id') || ''); + this.projectRef = projectRef || (this.optsEl.data('autocomplete-project-ref') || ''); this.dropdown = this.wrap.find('.dropdown'); this.dropdownContent = this.dropdown.find('.dropdown-content'); this.locationBadgeEl = this.getElement('.location-badge'); @@ -33,6 +27,7 @@ this.repositoryInputEl = this.getElement('#repository_ref'); this.clearInput = this.getElement('.js-clear-input'); this.saveOriginalState(); + // Only when user is logged in if (gon.current_user_id) { this.createAutocomplete(); } @@ -41,19 +36,28 @@ this.bindEvents(); } - SearchAutocomplete.prototype.getElement = function(selector) { + // Finds an element inside wrapper element + bindEventContext() { + this.onSearchInputBlur = this.onSearchInputBlur.bind(this); + this.onClearInputClick = this.onClearInputClick.bind(this); + this.onSearchInputFocus = this.onSearchInputFocus.bind(this); + this.onSearchInputClick = this.onSearchInputClick.bind(this); + this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); + this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this); + } + getElement(selector) { return this.wrap.find(selector); - }; + } - SearchAutocomplete.prototype.saveOriginalState = function() { + saveOriginalState() { return this.originalState = this.serializeState(); - }; + } - SearchAutocomplete.prototype.saveTextLength = function() { + saveTextLength() { return this.lastTextLength = this.searchInput.val().length; - }; + } - SearchAutocomplete.prototype.createAutocomplete = function() { + createAutocomplete() { return this.searchInput.glDropdown({ filterInputBlur: false, filterable: true, @@ -68,9 +72,9 @@ selectable: true, clicked: this.onClick.bind(this) }); - }; + } - SearchAutocomplete.prototype.getData = function(term, callback) { + getData(term, callback) { var _this, contents, jqXHR; _this = this; if (!term) { @@ -80,6 +84,7 @@ } return; } + // Prevent multiple ajax calls if (this.loadingSuggestions) { return; } @@ -90,14 +95,17 @@ term: term }, function(response) { var data, firstCategory, i, lastCategory, len, suggestion; + // Hide dropdown menu if no suggestions returns if (!response.length) { _this.disableAutocomplete(); return; } data = []; + // List results firstCategory = true; for (i = 0, len = response.length; i < len; i++) { suggestion = response[i]; + // Add group header before list each group if (lastCategory !== suggestion.category) { if (!firstCategory) { data.push('separator'); @@ -117,6 +125,7 @@ url: suggestion.url }); } + // Add option to proceed with the search if (data.length) { data.push('separator'); data.push({ @@ -128,9 +137,9 @@ }).always(function() { return _this.loadingSuggestions = false; }); - }; + } - SearchAutocomplete.prototype.getCategoryContents = function() { + getCategoryContents() { var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, utils; userId = gon.current_user_id; utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions; @@ -163,20 +172,22 @@ items.splice(0, 1); } return items; - }; + } - SearchAutocomplete.prototype.serializeState = function() { + serializeState() { return { + // Search Criteria search_project_id: this.projectInputEl.val(), group_id: this.groupInputEl.val(), search_code: this.searchCodeInputEl.val(), repository_ref: this.repositoryInputEl.val(), scope: this.scopeInputEl.val(), + // Location badge _location: this.locationBadgeEl.text() }; - }; + } - SearchAutocomplete.prototype.bindEvents = function() { + bindEvents() { this.searchInput.on('keydown', this.onSearchInputKeyDown); this.searchInput.on('keyup', this.onSearchInputKeyUp); this.searchInput.on('click', this.onSearchInputClick); @@ -188,10 +199,11 @@ return _this.searchInput.focus(); }; })(this)); - }; + } - SearchAutocomplete.prototype.enableAutocomplete = function() { + enableAutocomplete() { var _this; + // No need to enable anything if user is not logged in if (!gon.current_user_id) { return; } @@ -203,19 +215,23 @@ } }; - SearchAutocomplete.prototype.onSearchInputKeyDown = function() { + // Saves last length of the entered text + onSearchInputKeyDown() { return this.saveTextLength(); - }; + } - SearchAutocomplete.prototype.onSearchInputKeyUp = function(e) { + onSearchInputKeyUp(e) { switch (e.keyCode) { case KEYCODE.BACKSPACE: + // when trying to remove the location badge if (this.lastTextLength === 0 && this.badgePresent()) { this.removeLocationBadge(); } + // When removing the last character and no badge is present if (this.lastTextLength === 1) { this.disableAutocomplete(); } + // When removing any character from existin value if (this.lastTextLength > 1) { this.enableAutocomplete(); } @@ -223,61 +239,72 @@ case KEYCODE.ESCAPE: this.restoreOriginalState(); break; + case KEYCODE.ENTER: + this.disableAutocomplete(); + break; + case KEYCODE.UP: + case KEYCODE.DOWN: + return; default: + // Handle the case when deleting the input value other than backspace + // e.g. Pressing ctrl + backspace or ctrl + x if (this.searchInput.val() === '') { this.disableAutocomplete(); } else { + // We should display the menu only when input is not empty if (e.keyCode !== KEYCODE.ENTER) { this.enableAutocomplete(); } } } this.wrap.toggleClass('has-value', !!e.target.value); - }; + } - SearchAutocomplete.prototype.onSearchInputClick = function(e) { + // Avoid falsy value to be returned + onSearchInputClick(e) { return e.stopImmediatePropagation(); - }; + } - SearchAutocomplete.prototype.onSearchInputFocus = function() { + onSearchInputFocus() { this.isFocused = true; this.wrap.addClass('search-active'); if (this.getValue() === '') { return this.getData(); } - }; + } - SearchAutocomplete.prototype.getValue = function() { + getValue() { return this.searchInput.val(); - }; + } - SearchAutocomplete.prototype.onClearInputClick = function(e) { + onClearInputClick(e) { e.preventDefault(); return this.searchInput.val('').focus(); - }; + } - SearchAutocomplete.prototype.onSearchInputBlur = function(e) { + onSearchInputBlur(e) { this.isFocused = false; this.wrap.removeClass('search-active'); + // If input is blank then restore state if (this.searchInput.val() === '') { return this.restoreOriginalState(); } - }; + } - SearchAutocomplete.prototype.addLocationBadge = function(item) { + addLocationBadge(item) { var badgeText, category, value; category = item.category != null ? item.category + ": " : ''; value = item.value != null ? item.value : ''; badgeText = "" + category + value; this.locationBadgeEl.text(badgeText).show(); return this.wrap.addClass('has-location-badge'); - }; + } - SearchAutocomplete.prototype.hasLocationBadge = function() { + hasLocationBadge() { return this.wrap.is('.has-location-badge'); }; - SearchAutocomplete.prototype.restoreOriginalState = function() { + restoreOriginalState() { var i, input, inputs, len; inputs = Object.keys(this.originalState); for (i = 0, len = inputs.length; i < len; i++) { @@ -291,46 +318,49 @@ value: this.originalState._location }); } - }; + } - SearchAutocomplete.prototype.badgePresent = function() { + badgePresent() { return this.locationBadgeEl.length; - }; + } - SearchAutocomplete.prototype.resetSearchState = function() { + resetSearchState() { var i, input, inputs, len, results; inputs = Object.keys(this.originalState); results = []; for (i = 0, len = inputs.length; i < len; i++) { input = inputs[i]; + // _location isnt a input if (input === '_location') { break; } results.push(this.getElement("#" + input).val('')); } return results; - }; + } - SearchAutocomplete.prototype.removeLocationBadge = function() { + removeLocationBadge() { this.locationBadgeEl.hide(); this.resetSearchState(); this.wrap.removeClass('has-location-badge'); return this.disableAutocomplete(); - }; + } - SearchAutocomplete.prototype.disableAutocomplete = function() { - this.searchInput.addClass('disabled'); - this.dropdown.removeClass('open'); - return this.restoreMenu(); - }; + disableAutocomplete() { + if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) { + this.searchInput.addClass('disabled'); + this.dropdown.removeClass('open').trigger('hidden.bs.dropdown'); + this.restoreMenu(); + } + } - SearchAutocomplete.prototype.restoreMenu = function() { + restoreMenu() { var html; html = "<ul> <li><a class='dropdown-menu-empty-link is-focused'>Loading...</a></li> </ul>"; return this.dropdownContent.html(html); }; - SearchAutocomplete.prototype.onClick = function(item, $el, e) { + onClick(item, $el, e) { if (location.pathname.indexOf(item.url) !== -1) { e.preventDefault(); if (!this.badgePresent) { @@ -353,8 +383,45 @@ } }; - return SearchAutocomplete; + } + + global.SearchAutocomplete = SearchAutocomplete; + + $(function() { + var $projectOptionsDataEl = $('.js-search-project-options'); + var $groupOptionsDataEl = $('.js-search-group-options'); + var $dashboardOptionsDataEl = $('.js-search-dashboard-options'); + + if ($projectOptionsDataEl.length) { + gl.projectOptions = gl.projectOptions || {}; + + var projectPath = $projectOptionsDataEl.data('project-path'); + + gl.projectOptions[projectPath] = { + name: $projectOptionsDataEl.data('name'), + issuesPath: $projectOptionsDataEl.data('issues-path'), + mrPath: $projectOptionsDataEl.data('mr-path') + }; + } + + if ($groupOptionsDataEl.length) { + gl.groupOptions = gl.groupOptions || {}; + + var groupPath = $groupOptionsDataEl.data('group-path'); + + gl.groupOptions[groupPath] = { + name: $groupOptionsDataEl.data('name'), + issuesPath: $groupOptionsDataEl.data('issues-path'), + mrPath: $groupOptionsDataEl.data('mr-path') + }; + } - })(); + if ($dashboardOptionsDataEl.length) { + gl.dashboardOptions = { + issuesPath: $dashboardOptionsDataEl.data('issues-path'), + mrPath: $dashboardOptionsDataEl.data('mr-path') + }; + } + }); -}).call(this); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 3b28332854a..3aa8536d40a 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -86,6 +86,7 @@ var defaultStopCallback; defaultStopCallback = Mousetrap.stopCallback; return function(e, element, combo) { + // allowed shortcuts if textarea, input, contenteditable are focused if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) { return false; } else { diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js index 6c78914d338..92ce31969e3 100644 --- a/app/assets/javascripts/shortcuts_find_file.js +++ b/app/assets/javascripts/shortcuts_find_file.js @@ -14,8 +14,10 @@ ShortcutsFindFile.__super__.constructor.call(this); _oldStopCallback = Mousetrap.stopCallback; Mousetrap.stopCallback = (function(_this) { + // override to fire shortcuts action when focus in textbox return function(event, element, combo) { if (element === _this.projectFindFile.inputElement[0] && (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')) { + // when press up/down key in textbox, cusor prevent to move to home/end event.preventDefault(); return false; } diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 3f3a8a9dfd9..235bf4f95ec 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -1,7 +1,5 @@ /*= require mousetrap */ - - /*= require shortcuts_navigation */ (function() { @@ -43,16 +41,20 @@ if (selected.trim() === "") { return; } + // Put a '>' character before each non-empty line in the selection quote = _.map(selected.split("\n"), function(val) { if (val.trim() !== '') { return "> " + val + "\n"; } }); + // If replyField already has some content, add a newline before our quote separator = replyField.val().trim() !== "" && "\n" || ''; replyField.val(function(_, current) { return current + separator + quote.join('') + "\n"; }); + // Trigger autosave for the added text replyField.trigger('input'); + // Focus the input field return replyField.focus(); } }; diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js index 469e25482bb..b04159420d1 100644 --- a/app/assets/javascripts/shortcuts_navigation.js +++ b/app/assets/javascripts/shortcuts_navigation.js @@ -34,6 +34,9 @@ Mousetrap.bind('g i', function() { return ShortcutsNavigation.findAndFollowLink('.shortcuts-issues'); }); + Mousetrap.bind('g l', function() { + ShortcutsNavigation.findAndFollowLink('.shortcuts-issue-boards'); + }); Mousetrap.bind('g m', function() { return ShortcutsNavigation.findAndFollowLink('.shortcuts-merge_requests'); }); diff --git a/app/assets/javascripts/sidebar.js b/app/assets/javascripts/sidebar.js deleted file mode 100644 index bd0c1194b36..00000000000 --- a/app/assets/javascripts/sidebar.js +++ /dev/null @@ -1,41 +0,0 @@ -(function() { - var collapsed, expanded, toggleSidebar; - - collapsed = 'page-sidebar-collapsed'; - - expanded = 'page-sidebar-expanded'; - - toggleSidebar = function() { - $('.page-with-sidebar').toggleClass(collapsed + " " + expanded); - $('.navbar-fixed-top').toggleClass("header-collapsed header-expanded"); - if ($.cookie('pin_nav') === 'true') { - $('.navbar-fixed-top').toggleClass('header-pinned-nav'); - $('.page-with-sidebar').toggleClass('page-sidebar-pinned'); - } - return setTimeout((function() { - var niceScrollBars; - niceScrollBars = $('.nav-sidebar').niceScroll(); - return niceScrollBars.updateScrollBar(); - }), 300); - }; - - $(document).off('click', 'body').on('click', 'body', function(e) { - var $nav, $target, $toggle, pageExpanded; - if ($.cookie('pin_nav') !== 'true') { - $target = $(e.target); - $nav = $target.closest('.sidebar-wrapper'); - pageExpanded = $('.page-with-sidebar').hasClass('page-sidebar-expanded'); - $toggle = $target.closest('.toggle-nav-collapse, .side-nav-toggle'); - if ($nav.length === 0 && pageExpanded && $toggle.length === 0) { - $('.page-with-sidebar').toggleClass('page-sidebar-collapsed page-sidebar-expanded'); - return $('.navbar-fixed-top').toggleClass('header-collapsed header-expanded'); - } - } - }); - - $(document).on("click", '.toggle-nav-collapse, .side-nav-toggle', function(e) { - e.preventDefault(); - return toggleSidebar(); - }); - -}).call(this); diff --git a/app/assets/javascripts/sidebar.js.es6 b/app/assets/javascripts/sidebar.js.es6 new file mode 100644 index 00000000000..755fac8107b --- /dev/null +++ b/app/assets/javascripts/sidebar.js.es6 @@ -0,0 +1,93 @@ +((global) => { + let singleton; + + const pinnedStateCookie = 'pin_nav'; + const sidebarBreakpoint = 1024; + + const pageSelector = '.page-with-sidebar'; + const navbarSelector = '.navbar-fixed-top'; + const sidebarWrapperSelector = '.sidebar-wrapper'; + const sidebarContentSelector = '.nav-sidebar'; + + const pinnedToggleSelector = '.js-nav-pin'; + const sidebarToggleSelector = '.toggle-nav-collapse, .side-nav-toggle'; + + const pinnedPageClass = 'page-sidebar-pinned'; + const expandedPageClass = 'page-sidebar-expanded'; + + const pinnedNavbarClass = 'header-sidebar-pinned'; + const expandedNavbarClass = 'header-sidebar-expanded'; + + class Sidebar { + constructor() { + if (!singleton) { + singleton = this; + singleton.init(); + } + return singleton; + } + + init() { + this.isPinned = $.cookie(pinnedStateCookie) === 'true'; + this.isExpanded = ( + window.innerWidth >= sidebarBreakpoint && + $(pageSelector).hasClass(expandedPageClass) + ); + $(document) + .on('click', sidebarToggleSelector, () => this.toggleSidebar()) + .on('click', pinnedToggleSelector, () => this.togglePinnedState()) + .on('click', 'html, body', (e) => this.handleClickEvent(e)) + .on('page:change', () => this.renderState()); + this.renderState(); + } + + handleClickEvent(e) { + if (this.isExpanded && (!this.isPinned || window.innerWidth < sidebarBreakpoint)) { + const $target = $(e.target); + const targetIsToggle = $target.closest(sidebarToggleSelector).length > 0; + const targetIsSidebar = $target.closest(sidebarWrapperSelector).length > 0; + if (!targetIsToggle && (!targetIsSidebar || $target.closest('a'))) { + this.toggleSidebar(); + } + } + } + + toggleSidebar() { + this.isExpanded = !this.isExpanded; + this.renderState(); + } + + togglePinnedState() { + this.isPinned = !this.isPinned; + if (!this.isPinned) { + this.isExpanded = false; + } + $.cookie(pinnedStateCookie, this.isPinned ? 'true' : 'false', { + path: gon.relative_url_root || '/', + expires: 3650 + }); + this.renderState(); + } + + renderState() { + $(pageSelector) + .toggleClass(pinnedPageClass, this.isPinned && this.isExpanded) + .toggleClass(expandedPageClass, this.isExpanded); + $(navbarSelector) + .toggleClass(pinnedNavbarClass, this.isPinned && this.isExpanded) + .toggleClass(expandedNavbarClass, this.isExpanded); + + const $pinnedToggle = $(pinnedToggleSelector); + const tooltipText = this.isPinned ? 'Unpin navigation' : 'Pin navigation'; + const tooltipState = $pinnedToggle.attr('aria-describedby') && this.isExpanded ? 'show' : 'hide'; + $pinnedToggle.attr('title', tooltipText).tooltip('fixTitle').tooltip(tooltipState); + + if (this.isExpanded) { + setTimeout(() => $(sidebarContentSelector).niceScroll().updateScrollBar(), 200); + } + } + } + + global.Sidebar = Sidebar; + +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index b9ae497b0e5..ee6af123268 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -10,12 +10,13 @@ ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>'; - COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. Click to expand it.</div>'; + COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <a class="click-to-expand">Click to expand it.</a></div>'; function SingleFileDiff(file) { this.file = file; this.toggleDiff = bind(this.toggleDiff, this); this.content = $('.diff-content', this.file); + this.$toggleIcon = $('.diff-toggle-caret', this.file); this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path'); this.isOpen = !this.diffForPath; if (this.diffForPath) { @@ -23,23 +24,35 @@ this.loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide(); this.content = null; this.collapsedContent.after(this.loadingContent); + this.$toggleIcon.addClass('fa-caret-right'); } else { this.collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide(); this.content.after(this.collapsedContent); + this.$toggleIcon.addClass('fa-caret-down'); } - this.collapsedContent.on('click', this.toggleDiff); - $('.file-title > a', this.file).on('click', this.toggleDiff); + $('.file-title, .click-to-expand', this.file).on('click', this.toggleDiff); } SingleFileDiff.prototype.toggleDiff = function(e) { + var $target = $(e.target); + if (!$target.hasClass('file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return; this.isOpen = !this.isOpen; if (!this.isOpen && !this.hasError) { this.content.hide(); - return this.collapsedContent.show(); + this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down'); + this.collapsedContent.show(); + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } } else if (this.content) { this.collapsedContent.hide(); - return this.content.show(); + this.content.show(); + this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } } else { + this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); return this.getContentHTML(); } }; @@ -57,7 +70,11 @@ _this.hasError = true; _this.content = $(ERROR_HTML); } - return _this.collapsedContent.after(_this.content); + _this.collapsedContent.after(_this.content); + + if (typeof DiffNotesApp !== 'undefined') { + DiffNotesApp.compileComponents(); + } }; })(this)); }; diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js new file mode 100644 index 00000000000..855e97eb301 --- /dev/null +++ b/app/assets/javascripts/snippet/snippet_bundle.js @@ -0,0 +1,12 @@ +/*= require_tree . */ + +(function() { + $(function() { + var editor = ace.edit("editor") + + $(".snippet-form-holder form").on('submit', function() { + $(".snippet-file-content").val(editor.getValue()); + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/snippets_list.js.es6 b/app/assets/javascripts/snippets_list.js.es6 new file mode 100644 index 00000000000..6f0996c0d2a --- /dev/null +++ b/app/assets/javascripts/snippets_list.js.es6 @@ -0,0 +1,11 @@ +(global => { + global.gl = global.gl || {}; + + gl.SnippetsList = function() { + var $holder = $('.snippets-list-holder'); + + $holder.find('.pagination').on('ajax:success', (e, data) => { + $holder.replaceWith(data.html); + }); + } +})(window); diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index dba62638c78..2ae7bf5fc15 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -1,9 +1,20 @@ +// Syntax Highlighter +// +// Applies a syntax highlighting color scheme CSS class to any element with the +// `js-syntax-highlight` class +// +// ### Example Markup +// +// <div class="js-syntax-highlight"></div> +// (function() { $.fn.syntaxHighlight = function() { var $children; if ($(this).hasClass('js-syntax-highlight')) { + // Given the element itself, apply highlighting return $(this).addClass(gon.user_color_scheme); } else { + // Given a parent element, recurse to any of its applicable children $children = $(this).find('.js-syntax-highlight'); if ($children.length) { return $children.syntaxHighlight(); diff --git a/app/assets/javascripts/templates/issuable_template_selector.js.es6 b/app/assets/javascripts/templates/issuable_template_selector.js.es6 new file mode 100644 index 00000000000..bd4e3c3d00d --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selector.js.es6 @@ -0,0 +1,59 @@ +/*= require ../blob/template_selector */ + +((global) => { + class IssuableTemplateSelector extends gl.TemplateSelector { + constructor(...args) { + super(...args); + this.projectPath = this.dropdown.data('project-path'); + this.namespacePath = this.dropdown.data('namespace-path'); + this.issuableType = this.wrapper.data('issuable-type'); + this.titleInput = $(`#${this.issuableType}_title`); + + let initialQuery = { + name: this.dropdown.data('selected') + }; + + if (initialQuery.name) this.requestFile(initialQuery); + + $('.reset-template', this.dropdown.parent()).on('click', () => { + this.setInputValueToTemplateContent(); + }); + + $('.no-template', this.dropdown.parent()).on('click', () => { + this.currentTemplate = ''; + this.setInputValueToTemplateContent(); + $('.dropdown-toggle-text', this.dropdown).text('Choose a template'); + }); + } + + requestFile(query) { + this.startLoadingSpinner(); + Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => { + this.currentTemplate = currentTemplate; + if (err) return; // Error handled by global AJAX error handler + this.stopLoadingSpinner(); + this.setInputValueToTemplateContent(true); + }); + return; + } + + setInputValueToTemplateContent(append) { + // `this.requestFileSuccess` sets the value of the description input field + // to the content of the template selected. If `append` is true, the + // template content will be appended to the previous value of the field, + // separated by a blank line if the previous value is non-empty. + if (this.titleInput.val() === '') { + // If the title has not yet been set, focus the title input and + // skip focusing the description input by setting `true` as the + // `skipFocus` option to `requestFileSuccess`. + this.requestFileSuccess(this.currentTemplate, {skipFocus: true, append}); + this.titleInput.focus(); + } else { + this.requestFileSuccess(this.currentTemplate, {skipFocus: false, append}); + } + return; + } + } + + global.IssuableTemplateSelector = IssuableTemplateSelector; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js.es6 b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 new file mode 100644 index 00000000000..4e8247b89e1 --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 @@ -0,0 +1,29 @@ +((global) => { + class IssuableTemplateSelectors { + constructor({ $dropdowns, editor } = {}) { + this.$dropdowns = $dropdowns || $('.js-issuable-selector'); + this.editor = editor || this.initEditor(); + + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + new gl.IssuableTemplateSelector({ + pattern: /(\.md)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-issuable-selector-wrap'), + dropdown: $dropdown, + editor: this.editor + }); + }); + } + + initEditor() { + let editor = $('.markdown-area'); + // Proxy ace-editor's .setValue to jQuery's .val + editor.setValue = editor.val; + editor.getValue = editor.val; + return editor; + } + } + + global.IssuableTemplateSelectors = IssuableTemplateSelectors; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js deleted file mode 100644 index 6e677fa8cc6..00000000000 --- a/app/assets/javascripts/todos.js +++ /dev/null @@ -1,144 +0,0 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - - this.Todos = (function() { - function Todos(opts) { - var ref; - if (opts == null) { - opts = {}; - } - this.allDoneClicked = bind(this.allDoneClicked, this); - this.doneClicked = bind(this.doneClicked, this); - this.el = (ref = opts.el) != null ? ref : $('.js-todos-options'); - this.perPage = this.el.data('perPage'); - this.clearListeners(); - this.initBtnListeners(); - } - - Todos.prototype.clearListeners = function() { - $('.done-todo').off('click'); - $('.js-todos-mark-all').off('click'); - return $('.todo').off('click'); - }; - - Todos.prototype.initBtnListeners = function() { - $('.done-todo').on('click', this.doneClicked); - $('.js-todos-mark-all').on('click', this.allDoneClicked); - return $('.todo').on('click', this.goToTodoUrl); - }; - - Todos.prototype.doneClicked = function(e) { - var $this; - e.preventDefault(); - e.stopImmediatePropagation(); - $this = $(e.currentTarget); - $this.disable(); - return $.ajax({ - type: 'POST', - url: $this.attr('href'), - dataType: 'json', - data: { - '_method': 'delete' - }, - success: (function(_this) { - return function(data) { - _this.redirectIfNeeded(data.count); - _this.clearDone($this.closest('li')); - return _this.updateBadges(data); - }; - })(this) - }); - }; - - Todos.prototype.allDoneClicked = function(e) { - var $this; - e.preventDefault(); - e.stopImmediatePropagation(); - $this = $(e.currentTarget); - $this.disable(); - return $.ajax({ - type: 'POST', - url: $this.attr('href'), - dataType: 'json', - data: { - '_method': 'delete' - }, - success: (function(_this) { - return function(data) { - $this.remove(); - $('.js-todos-list').remove(); - return _this.updateBadges(data); - }; - })(this) - }); - }; - - Todos.prototype.clearDone = function($row) { - var $ul; - $ul = $row.closest('ul'); - $row.remove(); - if (!$ul.find('li').length) { - return $ul.parents('.panel').remove(); - } - }; - - Todos.prototype.updateBadges = function(data) { - $('.todos-pending .badge, .todos-pending-count').text(data.count); - return $('.todos-done .badge').text(data.done_count); - }; - - Todos.prototype.getTotalPages = function() { - return this.el.data('totalPages'); - }; - - Todos.prototype.getCurrentPage = function() { - return this.el.data('currentPage'); - }; - - Todos.prototype.getTodosPerPage = function() { - return this.el.data('perPage'); - }; - - Todos.prototype.redirectIfNeeded = function(total) { - var currPage, currPages, newPages, pageParams, url; - currPages = this.getTotalPages(); - currPage = this.getCurrentPage(); - if (!total) { - location.reload(); - return; - } - if (!currPages) { - return; - } - newPages = Math.ceil(total / this.getTodosPerPage()); - url = location.href; - if (newPages !== currPages) { - if (currPages > 1 && currPage === currPages) { - pageParams = { - page: currPages - 1 - }; - url = gl.utils.mergeUrlParams(pageParams, url); - } - return Turbolinks.visit(url); - } - }; - - Todos.prototype.goToTodoUrl = function(e) { - var todoLink; - todoLink = $(this).data('url'); - if (!todoLink) { - return; - } - if (e.metaKey || e.which === 2) { - e.preventDefault(); - return window.open(todoLink, '_blank'); - } else { - return Turbolinks.visit(todoLink); - } - }; - - return Todos; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/todos.js.es6 b/app/assets/javascripts/todos.js.es6 new file mode 100644 index 00000000000..055228c5df8 --- /dev/null +++ b/app/assets/javascripts/todos.js.es6 @@ -0,0 +1,161 @@ +((global) => { + + class Todos { + constructor({ el } = {}) { + this.allDoneClicked = this.allDoneClicked.bind(this); + this.doneClicked = this.doneClicked.bind(this); + this.el = el || $('.js-todos-options'); + this.perPage = this.el.data('perPage'); + this.clearListeners(); + this.initBtnListeners(); + this.initFilters(); + } + + clearListeners() { + $('.done-todo').off('click'); + $('.js-todos-mark-all').off('click'); + return $('.todo').off('click'); + } + + initBtnListeners() { + $('.done-todo').on('click', this.doneClicked); + $('.js-todos-mark-all').on('click', this.allDoneClicked); + return $('.todo').on('click', this.goToTodoUrl); + } + + initFilters() { + new UsersSelect(); + this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']); + this.initFilterDropdown($('.js-type-search'), 'type'); + this.initFilterDropdown($('.js-action-search'), 'action_id'); + + $('form.filter-form').on('submit', function (event) { + event.preventDefault(); + Turbolinks.visit(this.action + '&' + $(this).serialize()); + }); + } + + initFilterDropdown($dropdown, fieldName, searchFields) { + $dropdown.glDropdown({ + fieldName, + selectable: true, + filterable: searchFields ? true : false, + search: { fields: searchFields }, + data: $dropdown.data('data'), + clicked: function() { + return $dropdown.closest('form.filter-form').submit(); + } + }) + } + + doneClicked(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + const $target = $(e.currentTarget); + $target.disable(); + return $.ajax({ + type: 'POST', + url: $target.attr('href'), + dataType: 'json', + data: { + '_method': 'delete' + }, + success: (data) => { + this.redirectIfNeeded(data.count); + this.clearDone($target.closest('li')); + return this.updateBadges(data); + } + }); + } + + allDoneClicked(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + $target = $(e.currentTarget); + $target.disable(); + return $.ajax({ + type: 'POST', + url: $target.attr('href'), + dataType: 'json', + data: { + '_method': 'delete' + }, + success: (data) => { + $target.remove(); + $('.prepend-top-default').html('<div class="nothing-here-block">You\'re all done!</div>'); + return this.updateBadges(data); + } + }); + } + + clearDone($row) { + const $ul = $row.closest('ul'); + $row.remove(); + if (!$ul.find('li').length) { + return $ul.parents('.panel').remove(); + } + } + + updateBadges(data) { + $('.todos-pending .badge, .todos-pending-count').text(data.count); + return $('.todos-done .badge').text(data.done_count); + } + + getTotalPages() { + return this.el.data('totalPages'); + } + + getCurrentPage() { + return this.el.data('currentPage'); + } + + getTodosPerPage() { + return this.el.data('perPage'); + } + + redirectIfNeeded(total) { + const currPages = this.getTotalPages(); + const currPage = this.getCurrentPage(); + + // Refresh if no remaining Todos + if (!total) { + window.location.reload(); + return; + } + // Do nothing if no pagination + if (!currPages) { + return; + } + + const newPages = Math.ceil(total / this.getTodosPerPage()); + let url = location.href; + + if (newPages !== currPages) { + // Redirect to previous page if there's one available + if (currPages > 1 && currPage === currPages) { + const pageParams = { + page: currPages - 1 + }; + url = gl.utils.mergeUrlParams(pageParams, url); + } + return Turbolinks.visit(url); + } + } + + goToTodoUrl(e) { + const todoLink = $(this).data('url'); + if (!todoLink) { + return; + } + // Allow Meta-Click or Mouse3-click to open in a new tab + if (e.metaKey || e.which === 2) { + e.preventDefault(); + return window.open(todoLink, '_blank'); + } else { + return Turbolinks.visit(todoLink); + } + } + } + + global.Todos = Todos; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 78e159a7ed9..9b7be17c4fe 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -2,6 +2,8 @@ this.TreeView = (function() { function TreeView() { this.initKeyNav(); + // Code browser tree slider + // Make the entire tree-item row clickable, but not if clicking another link (like a commit message) $(".tree-content-holder .tree-item").on('click', function(e) { var $clickedEl, path; $clickedEl = $(e.target); @@ -15,6 +17,7 @@ } } }); + // Show the "Loading commit data" for only the first element $('span.log_loading:first').removeClass('hide'); } diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js index 9ba847fb0c2..ce2930c7fc7 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/u2f/authenticate.js @@ -1,3 +1,7 @@ +// Authenticate U2F (universal 2nd factor) devices for users to authenticate with. +// +// State Flow #1: setup -> in_progress -> authenticated -> POST to server +// State Flow #2: setup -> in_progress -> error -> setup (function() { var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; @@ -15,6 +19,17 @@ this.appId = u2fParams.app_id; this.challenge = u2fParams.challenge; this.signRequests = u2fParams.sign_requests.map(function(request) { + // The U2F Javascript API v1.1 requires a single challenge, with + // _no challenges per-request_. The U2F Javascript API v1.0 requires a + // challenge per-request, which is done by copying the single challenge + // into every request. + // + // In either case, we don't need the per-request challenges that the server + // has generated, so we can remove them. + // + // Note: The server library fixes this behaviour in (unreleased) version 1.0.0. + // This can be removed once we upgrade. + // https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4 return _(request).omit('challenge'); }); } @@ -41,6 +56,7 @@ })(this), 10); }; + // Rendering # U2FAuthenticate.prototype.templates = { "notSupported": "#js-authenticate-u2f-not-supported", "setup": '#js-authenticate-u2f-setup', @@ -75,6 +91,8 @@ U2FAuthenticate.prototype.renderAuthenticated = function(deviceResponse) { this.renderTemplate('authenticated'); + // Prefer to do this instead of interpolating using Underscore templates + // because of JSON escaping issues. return this.container.find("#js-device-response").val(deviceResponse); }; diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js index c87e0840df3..926912fa988 100644 --- a/app/assets/javascripts/u2f/register.js +++ b/app/assets/javascripts/u2f/register.js @@ -1,3 +1,7 @@ +// Register U2F (universal 2nd factor) devices for users to authenticate with. +// +// State Flow #1: setup -> in_progress -> registered -> POST to server +// State Flow #2: setup -> in_progress -> error -> setup (function() { var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; @@ -39,6 +43,7 @@ })(this), 10); }; + // Rendering # U2FRegister.prototype.templates = { "notSupported": "#js-register-u2f-not-supported", "setup": '#js-register-u2f-setup', @@ -73,6 +78,8 @@ U2FRegister.prototype.renderRegistered = function(deviceResponse) { this.renderTemplate('registered'); + // Prefer to do this instead of interpolating using Underscore templates + // because of JSON escaping issues. return this.container.find("#js-device-response").val(deviceResponse); }; diff --git a/app/assets/javascripts/user.js b/app/assets/javascripts/user.js deleted file mode 100644 index b46390ad8f4..00000000000 --- a/app/assets/javascripts/user.js +++ /dev/null @@ -1,31 +0,0 @@ -(function() { - this.User = (function() { - function User(opts) { - this.opts = opts; - $('.profile-groups-avatars').tooltip({ - "placement": "top" - }); - this.initTabs(); - $('.hide-project-limit-message').on('click', function(e) { - var path; - path = '/'; - $.cookie('hide_project_limit_message', 'false', { - path: path - }); - $(this).parents('.project-limit-message').remove(); - return e.preventDefault(); - }); - } - - User.prototype.initTabs = function() { - return new UserTabs({ - parentEl: '.user-profile', - action: this.opts.action - }); - }; - - return User; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/user.js.es6 b/app/assets/javascripts/user.js.es6 new file mode 100644 index 00000000000..0f97924d94e --- /dev/null +++ b/app/assets/javascripts/user.js.es6 @@ -0,0 +1,34 @@ +((global) => { + global.User = class { + constructor({ action }) { + this.action = action; + this.placeProfileAvatarsToTop(); + this.initTabs(); + this.hideProjectLimitMessage(); + } + + placeProfileAvatarsToTop() { + $('.profile-groups-avatars').tooltip({ + placement: 'top' + }); + } + + initTabs() { + return new global.UserTabs({ + parentEl: '.user-profile', + action: this.action + }); + } + + hideProjectLimitMessage() { + $('.hide-project-limit-message').on('click', e => { + e.preventDefault(); + const path = gon.relative_url_root || '/'; + $.cookie('hide_project_limit_message', 'false', { + path: path + }); + $(this).parents('.project-limit-message').remove(); + }); + } + } +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/user_tabs.js b/app/assets/javascripts/user_tabs.js deleted file mode 100644 index e5e75701fee..00000000000 --- a/app/assets/javascripts/user_tabs.js +++ /dev/null @@ -1,119 +0,0 @@ -(function() { - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - - this.UserTabs = (function() { - function UserTabs(opts) { - this.tabShown = bind(this.tabShown, this); - var i, item, len, ref, ref1, ref2, ref3; - this.action = (ref = opts.action) != null ? ref : 'activity', this.defaultAction = (ref1 = opts.defaultAction) != null ? ref1 : 'activity', this.parentEl = (ref2 = opts.parentEl) != null ? ref2 : $(document); - if (typeof this.parentEl === 'string') { - this.parentEl = $(this.parentEl); - } - this._location = location; - this.loaded = {}; - ref3 = this.parentEl.find('.nav-links a'); - for (i = 0, len = ref3.length; i < len; i++) { - item = ref3[i]; - this.loaded[$(item).attr('data-action')] = false; - } - this.actions = Object.keys(this.loaded); - this.bindEvents(); - if (this.action === 'show') { - this.action = this.defaultAction; - } - this.activateTab(this.action); - } - - UserTabs.prototype.bindEvents = function() { - return this.parentEl.off('shown.bs.tab', '.nav-links a[data-toggle="tab"]').on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', this.tabShown); - }; - - UserTabs.prototype.tabShown = function(event) { - var $target, action, source; - $target = $(event.target); - action = $target.data('action'); - source = $target.attr('href'); - this.setTab(source, action); - return this.setCurrentAction(action); - }; - - UserTabs.prototype.activateTab = function(action) { - return this.parentEl.find(".nav-links .js-" + action + "-tab a").tab('show'); - }; - - UserTabs.prototype.setTab = function(source, action) { - if (this.loaded[action] === true) { - return; - } - if (action === 'activity') { - this.loadActivities(source); - } - if (action === 'groups' || action === 'contributed' || action === 'projects' || action === 'snippets') { - return this.loadTab(source, action); - } - }; - - UserTabs.prototype.loadTab = function(source, action) { - return $.ajax({ - beforeSend: (function(_this) { - return function() { - return _this.toggleLoading(true); - }; - })(this), - complete: (function(_this) { - return function() { - return _this.toggleLoading(false); - }; - })(this), - dataType: 'json', - type: 'GET', - url: source + ".json", - success: (function(_this) { - return function(data) { - var tabSelector; - tabSelector = 'div#' + action; - _this.parentEl.find(tabSelector).html(data.html); - _this.loaded[action] = true; - return gl.utils.localTimeAgo($('.js-timeago', tabSelector)); - }; - })(this) - }); - }; - - UserTabs.prototype.loadActivities = function(source) { - var $calendarWrap; - if (this.loaded['activity'] === true) { - return; - } - $calendarWrap = this.parentEl.find('.user-calendar'); - $calendarWrap.load($calendarWrap.data('href')); - new Activities(); - return this.loaded['activity'] = true; - }; - - UserTabs.prototype.toggleLoading = function(status) { - return this.parentEl.find('.loading-status .loading').toggle(status); - }; - - UserTabs.prototype.setCurrentAction = function(action) { - var new_state, regExp; - regExp = new RegExp('\/(' + this.actions.join('|') + ')(\.html)?\/?$'); - new_state = this._location.pathname; - new_state = new_state.replace(/\/+$/, ""); - new_state = new_state.replace(regExp, ''); - if (action !== this.defaultAction) { - new_state += "/" + action; - } - new_state += this._location.search + this._location.hash; - history.replaceState({ - turbolinks: true, - url: new_state - }, document.title, new_state); - return new_state; - }; - - return UserTabs; - - })(); - -}).call(this); diff --git a/app/assets/javascripts/user_tabs.js.es6 b/app/assets/javascripts/user_tabs.js.es6 new file mode 100644 index 00000000000..dfdfa1e7f75 --- /dev/null +++ b/app/assets/javascripts/user_tabs.js.es6 @@ -0,0 +1,157 @@ +/* +UserTabs + +Handles persisting and restoring the current tab selection and lazily-loading +content on the Users#show page. + +### Example Markup + + <ul class="nav-links"> + <li class="activity-tab active"> + <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username"> + Activity + </a> + </li> + <li class="groups-tab"> + <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups"> + Groups + </a> + </li> + <li class="contributed-tab"> + <a data-action="contributed" data-target="#contributed" data-toggle="tab" href="/u/username/contributed"> + Contributed projects + </a> + </li> + <li class="projects-tab"> + <a data-action="projects" data-target="#projects" data-toggle="tab" href="/u/username/projects"> + Personal projects + </a> + </li> + <li class="snippets-tab"> + <a data-action="snippets" data-target="#snippets" data-toggle="tab" href="/u/username/snippets"> + </a> + </li> + </ul> + + <div class="tab-content"> + <div class="tab-pane" id="activity"> + Activity Content + </div> + <div class="tab-pane" id="groups"> + Groups Content + </div> + <div class="tab-pane" id="contributed"> + Contributed projects content + </div> + <div class="tab-pane" id="projects"> + Projects content + </div> + <div class="tab-pane" id="snippets"> + Snippets content + </div> + </div> + + <div class="loading-status"> + <div class="loading"> + Loading Animation + </div> + </div> +*/ +((global) => { + class UserTabs { + constructor ({ defaultAction, action, parentEl }) { + this.loaded = {}; + this.defaultAction = defaultAction || 'activity'; + this.action = action || this.defaultAction; + this.$parentEl = $(parentEl) || $(document); + this._location = window.location; + this.$parentEl.find('.nav-links a') + .each((i, navLink) => { + this.loaded[$(navLink).attr('data-action')] = false; + }); + this.actions = Object.keys(this.loaded); + this.bindEvents(); + + if (this.action === 'show') { + this.action = this.defaultAction; + } + + this.activateTab(this.action); + } + + bindEvents() { + return this.$parentEl.off('shown.bs.tab', '.nav-links a[data-toggle="tab"]') + .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event)); + } + + tabShown(event) { + const $target = $(event.target); + const action = $target.data('action'); + const source = $target.attr('href'); + this.setTab(source, action); + return this.setCurrentAction(source, action); + } + + activateTab(action) { + return this.$parentEl.find(`.nav-links .js-${action}-tab a`) + .tab('show'); + } + + setTab(source, action) { + if (this.loaded[action]) { + return; + } + if (action === 'activity') { + this.loadActivities(source); + } + + const loadableActions = [ 'groups', 'contributed', 'projects', 'snippets' ]; + if (loadableActions.indexOf(action) > -1) { + return this.loadTab(source, action); + } + } + + loadTab(source, action) { + return $.ajax({ + beforeSend: () => this.toggleLoading(true), + complete: () => this.toggleLoading(false), + dataType: 'json', + type: 'GET', + url: `${source}.json`, + success: (data) => { + const tabSelector = `div#${action}`; + this.$parentEl.find(tabSelector).html(data.html); + this.loaded[action] = true; + return gl.utils.localTimeAgo($('.js-timeago', tabSelector)); + } + }); + } + + loadActivities(source) { + if (this.loaded['activity']) { + return; + } + const $calendarWrap = this.$parentEl.find('.user-calendar'); + $calendarWrap.load($calendarWrap.data('href')); + new Activities(); + return this.loaded['activity'] = true; + } + + toggleLoading(status) { + return this.$parentEl.find('.loading-status .loading') + .toggle(status); + } + + setCurrentAction(source, action) { + let new_state = source + new_state = new_state.replace(/\/+$/, ''); + new_state += this._location.search + this._location.hash; + history.replaceState({ + turbolinks: true, + url: new_state + }, document.title, new_state); + return new_state; + } + } + global.UserTabs = UserTabs; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 8b3dbf5f5ae..3bd4c3c066f 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -3,7 +3,6 @@ this.Calendar = (function() { function Calendar(timestamps, calendar_activities_path) { - var group, i; this.calendar_activities_path = calendar_activities_path; this.clickDay = bind(this.clickDay, this); this.currentSelectedDate = ''; @@ -12,29 +11,46 @@ this.daySizeWithSpace = this.daySize + (this.daySpace * 2); this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; this.months = []; + // Loop through the timestamps to create a group of objects + // The group of objects will be grouped based on the day of the week they are this.timestampsTmp = []; - i = 0; - group = 0; - _.each(timestamps, (function(_this) { - return function(count, date) { - var day, innerArray, newDate; - newDate = new Date(parseInt(date) * 1000); - day = newDate.getDay(); - if ((day === 0 && i !== 0) || i === 0) { - _this.timestampsTmp.push([]); - group++; - } - innerArray = _this.timestampsTmp[group - 1]; - innerArray.push({ - count: count, - date: newDate, - day: day - }); - return i++; - }; - })(this)); + var group = 0; + + var today = new Date() + today.setHours(0, 0, 0, 0, 0); + + var oneYearAgo = new Date(today); + oneYearAgo.setFullYear(today.getFullYear() - 1); + + var days = gl.utils.getDayDifference(oneYearAgo, today); + + for(var i = 0; i <= days; i++) { + var date = new Date(oneYearAgo); + date.setDate(date.getDate() + i); + + var day = date.getDay(); + var count = timestamps[dateFormat(date, 'yyyy-mm-dd')]; + + // Create a new group array if this is the first day of the week + // or if is first object + if ((day === 0 && i !== 0) || i === 0) { + this.timestampsTmp.push([]); + group++; + } + + var innerArray = this.timestampsTmp[group - 1]; + // Push to the inner array the values that will be used to render map + innerArray.push({ + count: count || 0, + date: date, + day: day + }); + } + + // Init color functions this.colorKey = this.initColorKey(); this.color = this.initColor(); + // Init the svg element this.renderSvg(group); this.renderDays(); this.renderMonths(); @@ -43,8 +59,22 @@ this.initTooltips(); } + // Add extra padding for the last month label if it is also the last column + Calendar.prototype.getExtraWidthPadding = function(group) { + var extraWidthPadding = 0; + var lastColMonth = this.timestampsTmp[group - 1][0].date.getMonth(); + var secondLastColMonth = this.timestampsTmp[group - 2][0].date.getMonth(); + + if (lastColMonth != secondLastColMonth) { + extraWidthPadding = 3; + } + + return extraWidthPadding; + } + Calendar.prototype.renderSvg = function(group) { - return this.svg = d3.select('.js-contrib-calendar').append('svg').attr('width', (group + 1) * this.daySizeWithSpace).attr('height', 167).attr('class', 'contrib-calendar'); + var width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group); + return this.svg = d3.select('.js-contrib-calendar').append('svg').attr('width', width).attr('height', 167).attr('class', 'contrib-calendar'); }; Calendar.prototype.renderDays = function() { diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js index b95faadc8e7..d6e4d9f7ad8 100644 --- a/app/assets/javascripts/users/users_bundle.js +++ b/app/assets/javascripts/users/users_bundle.js @@ -3,5 +3,4 @@ (function() { - }).call(this); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 4af2a214e12..6aa0e1cd2b6 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -13,14 +13,16 @@ } $('.js-user-search').each((function(_this) { return function(i, dropdown) { - var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser; + var options = {}; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove; $dropdown = $(dropdown); - _this.projectId = $dropdown.data('project-id'); - _this.showCurrentUser = $dropdown.data('current-user'); + options.projectId = $dropdown.data('project-id'); + options.showCurrentUser = $dropdown.data('current-user'); showNullUser = $dropdown.data('null-user'); + showMenuAbove = $dropdown.data('showMenuAbove'); showAnyUser = $dropdown.data('any-user'); firstUser = $dropdown.data('first-user'); - _this.authorId = $dropdown.data('author-id'); + options.authorId = $dropdown.data('author-id'); selectedId = $dropdown.data('selected'); defaultLabel = $dropdown.data('default-label'); issueURL = $dropdown.data('issueUpdate'); @@ -69,17 +71,19 @@ return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; - collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/u/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); - assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/u/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); + collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); + assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, data: function(term, callback) { var isAuthorFilter; isAuthorFilter = $('.js-author-search'); - return _this.users(term, function(users) { + return _this.users(term, options, function(users) { var anyUser, index, j, len, name, obj, showDivider; if (term.length === 0) { showDivider = 0; if (firstUser) { + // Move current user to the front of the list for (index = j = 0, len = users.length; j < len; index = ++j) { obj = users[index]; if (obj.username === firstUser) { @@ -114,7 +118,11 @@ if (showDivider) { users.splice(showDivider, 0, "divider"); } - return callback(users); + + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }); }, filterable: true, @@ -124,8 +132,8 @@ }, selectable: true, fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected) { - if (selected && 'id' in selected) { + toggleLabel: function(selected, el) { + if (selected && 'id' in selected && $(el).hasClass('is-active')) { if (selected.text) { return selected.text; } else { @@ -135,20 +143,29 @@ return defaultLabel; } }, + defaultLabel: defaultLabel, inputId: 'issue_assignee_id', hidden: function(e) { $selectbox.hide(); + // display:block overrides the hide-collapse rule return $value.css('display', ''); }, - clicked: function(user) { + clicked: function(user, $el, e) { var isIssueIndex, isMRIndex, page, selected; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); - if ($dropdown.hasClass('js-filter-bulk-update')) { + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); + selectedId = user.id; return; } - if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if ($('html').hasClass('issue-boards-page')) { + selectedId = user.id; + gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id; + gl.issueBoards.BoardsStore.updateFiltersUrl(); + e.preventDefault(); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { selectedId = user.id; return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { @@ -158,6 +175,9 @@ return assignTo(selected); } }, + id: function (user) { + return user.id; + }, renderRow: function(user) { var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; username = user.username ? "@" + user.username : ""; @@ -171,6 +191,7 @@ img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; } } + // split into three parts so we can remove the username section if nessesary listWithName = "<li> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; listClosingTags = "</a> </li>"; @@ -185,11 +206,14 @@ $('.ajax-users-select').each((function(_this) { return function(i, select) { var firstUser, showAnyUser, showEmailUser, showNullUser; - _this.projectId = $(select).data('project-id'); - _this.groupId = $(select).data('group-id'); - _this.showCurrentUser = $(select).data('current-user'); - _this.authorId = $(select).data('author-id'); - _this.skipUsers = $(select).data('skip-users'); + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); showNullUser = $(select).data('null-user'); showAnyUser = $(select).data('any-user'); showEmailUser = $(select).data('email-user'); @@ -199,13 +223,14 @@ multiple: $(select).hasClass('multiselect'), minimumInputLength: 0, query: function(query) { - return _this.users(query.term, function(users) { + return _this.users(query.term, options, function(users) { var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; data = { results: users }; if (query.term.length === 0) { if (firstUser) { + // Move current user to the front of the list ref = data.results; for (index = j = 0, len = ref.length; j < len; index = ++j) { obj = ref[index]; @@ -236,10 +261,11 @@ } } if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + var trimmed = query.term.trim(); emailUser = { name: "Invite \"" + query.term + "\"", - username: query.term, - id: query.term + username: trimmed, + id: trimmed }; data.results.unshift(emailUser); } @@ -262,6 +288,7 @@ return _this.formatSelection.apply(_this, args); }, dropdownCssClass: "ajax-users-dropdown", + // we do not want to escape markup since we are displaying html in results escapeMarkup: function(m) { return m; } @@ -309,7 +336,9 @@ }); }; - UsersSelect.prototype.users = function(query, callback) { + // Return users list. Filtered by query + // Only active users retrieved + UsersSelect.prototype.users = function(query, options, callback) { var url; url = this.buildUrl(this.usersPath); return $.ajax({ @@ -318,11 +347,13 @@ search: query, per_page: 20, active: true, - project_id: this.projectId, - group_id: this.groupId, - current_user: this.showCurrentUser, - author_id: this.authorId, - skip_users: this.skipUsers + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null }, dataType: "json" }).done(function(users) { diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 71236c6a27d..777b32b41c9 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,21 +1,34 @@ - +// Zen Mode (full screen) textarea +// /*= provides zen_mode:enter */ - - /*= provides zen_mode:leave */ - - +// /*= require jquery.scrollTo */ - - /*= require dropzone */ - - /*= require mousetrap */ - - /*= require mousetrap/pause */ +// +// ### Events +// +// `zen_mode:enter` +// +// Fired when the "Edit in fullscreen" link is clicked. +// +// **Synchronicity** Sync +// **Bubbles** Yes +// **Cancelable** No +// **Target** a.js-zen-enter +// +// `zen_mode:leave` +// +// Fired when the "Leave Fullscreen" link is clicked. +// +// **Synchronicity** Sync +// **Bubbles** Yes +// **Cancelable** No +// **Target** a.js-zen-leave +// (function() { this.ZenMode = (function() { function ZenMode() { @@ -40,6 +53,7 @@ }; })(this)); $(document).on('keydown', function(e) { + // Esc if (e.keyCode === 27) { e.preventDefault(); return $(document).trigger('zen_mode:leave'); @@ -52,6 +66,7 @@ this.active_backdrop = $(backdrop); this.active_backdrop.addClass('fullscreen'); this.active_textarea = this.active_backdrop.find('textarea'); + // Prevent a user-resized textarea from persisting to fullscreen this.active_textarea.removeAttr('style'); return this.active_textarea.focus(); }; diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss index 542a53f0377..897bc49e7df 100644 --- a/app/assets/stylesheets/behaviors.scss +++ b/app/assets/stylesheets/behaviors.scss @@ -20,3 +20,8 @@ .turn-off { display: block; } } } + +// Hide element if Vue is still working on rendering it fully. +[v-cloak="true"] { + display: none !important; +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index a306b8f3f29..d5cca1b10fb 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -24,6 +24,7 @@ @import "framework/issue_box.scss"; @import "framework/jquery.scss"; @import "framework/lists.scss"; +@import "framework/logo.scss"; @import "framework/markdown_area.scss"; @import "framework/mobile.scss"; @import "framework/modal.scss"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 1fec61bdba1..1e9a45c19b8 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -8,65 +8,44 @@ // Copyright (c) 2016 Daniel Eden .animated { - -webkit-animation-duration: 1s; - animation-duration: 1s; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; -} - -.animated.infinite { - -webkit-animation-iteration-count: infinite; - animation-iteration-count: infinite; -} + @include webkit-prefix(animation-duration, 1s); + @include webkit-prefix(animation-fill-mode, both); -.animated.hinge { - -webkit-animation-duration: 2s; - animation-duration: 2s; -} + &.infinite { + @include webkit-prefix(animation-iteration-count, infinite); + } -.animated.flipOutX, -.animated.flipOutY, -.animated.bounceIn, -.animated.bounceOut { - -webkit-animation-duration: .75s; - animation-duration: .75s; -} + &.once { + @include webkit-prefix(animation-iteration-count, 1); + } -@-webkit-keyframes pulse { - from { - -webkit-transform: scale3d(1, 1, 1); - transform: scale3d(1, 1, 1); + &.hinge { + @include webkit-prefix(animation-duration, 2s); } - 50% { - -webkit-transform: scale3d(1.05, 1.05, 1.05); - transform: scale3d(1.05, 1.05, 1.05); + &.flipOutX, + &.flipOutY, + &.bounceIn, + &.bounceOut { + @include webkit-prefix(animation-duration, .75s); } - to { - -webkit-transform: scale3d(1, 1, 1); - transform: scale3d(1, 1, 1); + &.short { + @include webkit-prefix(animation-duration, 321ms); + @include webkit-prefix(animation-fill-mode, none); } } -@keyframes pulse { - from { - -webkit-transform: scale3d(1, 1, 1); - transform: scale3d(1, 1, 1); +@include keyframes(pulse) { + from, to { + @include webkit-prefix(transform, scale3d(1, 1, 1)); } 50% { - -webkit-transform: scale3d(1.05, 1.05, 1.05); - transform: scale3d(1.05, 1.05, 1.05); - } - - to { - -webkit-transform: scale3d(1, 1, 1); - transform: scale3d(1, 1, 1); + @include webkit-prefix(transform, scale3d(1.05, 1.05, 1.05)); } } .pulse { - -webkit-animation-name: pulse; - animation-name: pulse; + @include webkit-prefix(animation-name, pulse); } diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index c79b22d4d21..98e301d3799 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -4,7 +4,7 @@ width: 40px; height: 40px; padding: 0; - @include border-radius($avatar_radius); + border-radius: $avatar_radius; border: 1px solid rgba(0, 0, 0, .1); &.avatar-inline { @@ -17,7 +17,7 @@ } &.avatar-tile { - @include border-radius(0); + border-radius: 0; border: none; } diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 7ce203d2ec7..8002e56724b 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -19,10 +19,8 @@ &.diff-collapsed { padding: 5px; - cursor: pointer; - - &:hover { - background-color: $row-hover; + .click-to-expand { + cursor: pointer; } } } @@ -129,27 +127,19 @@ position: relative; .avatar-holder { - margin-bottom: 16px; - .avatar, .identicon { margin: 0 auto; float: none; } .identicon { - @include border-radius(50%); + border-radius: 50%; } } .cover-title { color: $gl-header-color; - margin: 0; - font-size: 24px; - font-weight: normal; - margin-bottom: 10px; - color: #4c4e54; font-size: 23px; - line-height: 1.1; h1 { color: $gl-gray-dark; @@ -213,6 +203,9 @@ } } } + &.user-cover-block { + padding: 24px 0 0; + } .group-info { @@ -249,6 +242,10 @@ > .controls { float: right; } + + .new-branch { + margin-top: 3px; + } } .content-block-small { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 473530cf094..a7c8d782e9b 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -1,5 +1,5 @@ @mixin btn-default { - @include border-radius(3px); + border-radius: 3px; font-size: $gl-font-size; font-weight: 500; padding: $gl-vert-padding $gl-btn-padding; @@ -8,7 +8,7 @@ &:active { outline: none; background-color: $btn-active-gray; - @include box-shadow($gl-btn-active-background); + box-shadow: $gl-btn-active-background; } } @@ -43,7 +43,7 @@ &:active, &.active { - @include box-shadow ($gl-btn-active-background); + box-shadow: $gl-btn-active-background; background-color: $dark; border-color: $border-dark; @@ -164,6 +164,10 @@ @include btn-outline($white-light, $orange-normal, $orange-normal, $orange-light, $white-light, $orange-light); } + &.btn-spam { + @include btn-outline($white-light, $red-normal, $red-normal, $red-light, $white-light, $red-light); + } + &.btn-danger, &.btn-remove, &.btn-red { @@ -190,16 +194,29 @@ pointer-events: none !important; } - .caret { + .fa-caret-down, + .fa-caret-up { margin-left: 5px; } + &.dropdown-toggle { + .fa-caret-down { + margin-left: 3px; + } + } + svg { height: 15px; - width: auto; + width: 15px; position: relative; top: 2px; } + + svg, .fa { + &:not(:last-child) { + margin-right: 3px; + } + } } .btn-lg { @@ -262,7 +279,7 @@ } .active { - @include box-shadow($gl-btn-active-background); + box-shadow: $gl-btn-active-background; border: 1px solid #c6cacf !important; background-color: #e4e7ed !important; @@ -326,3 +343,9 @@ box-shadow: inset 0 0 0 white; } } + +@media (max-width: $screen-xs-max) { + .btn-wide-on-xs { + width: 100%; + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index c1e5305644b..5957dce89bc 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -53,7 +53,7 @@ pre { &.well-pre { border: 1px solid #eee; - background: #f9f9f9; + background: $gray-light; border-radius: 0; color: #555; } @@ -225,7 +225,7 @@ li.note { .milestone { &.milestone-closed { - background: #f9f9f9; + background: $gray-light; } .progress { margin-bottom: 0; @@ -248,7 +248,7 @@ li.note { img.emoji { height: 20px; - vertical-align: middle; + vertical-align: top; width: 20px; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index c54eb0d6479..baa95711329 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -1,22 +1,11 @@ -.caret { - display: inline-block; - width: 0; - height: 0; - margin-left: 2px; - vertical-align: middle; - border-top: $caret-width-base dashed; - border-right: $caret-width-base solid transparent; - border-left: $caret-width-base solid transparent; -} - -.btn-group { - .caret { - margin-left: 0; - } -} - .dropdown { position: relative; + + .btn-link { + &:hover { + cursor: pointer; + } + } } .open { @@ -56,9 +45,13 @@ position: absolute; top: 50%; right: 6px; - margin-top: -4px; + margin-top: -6px; color: $dropdown-toggle-icon-color; font-size: 10px; + &.fa-spinner { + font-size: 16px; + margin-top: -8px; + } } &:hover, { @@ -72,6 +65,23 @@ &.large { width: 200px; } + + &.wide { + width: 100%; + + + .dropdown-select { + width: 100%; + } + } + + // Allows dynamic-width text in the dropdown toggle. + // Resizes to allow long text without overflowing the container. + &.dynamic { + width: auto; + min-width: 160px; + max-width: 100%; + padding-right: 25px; + } } .dropdown-menu, @@ -156,6 +166,13 @@ &.dropdown-menu-user-link { line-height: 16px; } + + .icon-play { + fill: $table-text-gray; + margin-right: 6px; + height: 12px; + width: 11px; + } } .dropdown-header { @@ -168,6 +185,12 @@ .separator + .dropdown-header { padding-top: 2px; } + + .unclickable { + cursor: not-allowed; + padding: 5px 8px; + color: $dropdown-header-color; + } } .dropdown-menu-large { @@ -398,6 +421,7 @@ font-size: 14px; a { + cursor: pointer; padding-left: 10px; } } @@ -563,3 +587,9 @@ display: block; color: $gl-placeholder-color; } + +.dropdown-toggle-text { + &.is-default { + color: $gl-placeholder-color; + } +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 407f1873431..81520500594 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -26,6 +26,15 @@ padding: 10px $gl-padding; word-wrap: break-word; border-radius: 3px 3px 0 0; + cursor: pointer; + + &:hover { + background-color: $dark-background-color; + } + + .diff-toggle-caret { + padding-right: 6px; + } &.file-title-clear { padding-left: 0; @@ -63,9 +72,10 @@ &.image_file { background: #eee; text-align: center; + img { - padding: 100px; - max-width: 50%; + padding: 20px; + max-width: 80%; } } @@ -93,7 +103,6 @@ &.blame { table { border: none; - box-shadow: none; margin: 0; } tr { @@ -107,19 +116,10 @@ border-right: none; } } - img.avatar { - border: 0 none; - float: none; - margin: 0; - padding: 0; - } td.blame-commit { - background: #f9f9f9; - min-width: 350px; - - .commit-author-link { - color: #888; - } + padding: 0 10px; + min-width: 400px; + background: $gray-light; } td.line-numbers { float: none; @@ -132,12 +132,6 @@ } td.lines { padding: 0; - code { - font-family: $monospace_font; - } - pre { - margin: 0; - } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 9209347f9bc..19827943385 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -1,6 +1,10 @@ .filter-item { margin-right: 6px; vertical-align: top; + + &.reset-filters { + padding: 7px; + } } @media (min-width: $screen-sm-min) { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 0c21d0240b3..a55dcf4a699 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -3,7 +3,8 @@ margin: 0; margin-bottom: $gl-padding; font-size: 14px; - z-index: 100; + position: relative; + z-index: 1; .flash-notice { @extend .alert; @@ -20,7 +21,8 @@ .flash-notice, .flash-alert { border-radius: $border-radius-default; - .container-fluid.container-limited.flash-text { + .container-fluid, + .container-fluid.container-limited { background: transparent; } } @@ -41,4 +43,3 @@ } } } - diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 43d55661541..05e8ee0190d 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -19,7 +19,6 @@ input[type='text'].danger { } .form-actions { - margin: -$gl-padding; margin-top: 0; margin-bottom: -$gl-padding; padding: $gl-padding; @@ -74,7 +73,7 @@ label { } .form-control { - @include box-shadow(none); + box-shadow: none; border-radius: 3px; padding: $gl-vert-padding $gl-input-padding; } @@ -82,10 +81,10 @@ label { .select-wrapper { position: relative; - .caret { + .fa-caret-down { position: absolute; right: 10px; - top: $gl-padding; + top: 10px; color: $gray-darkest; pointer-events: none; } diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index f4d35c4b4b1..c0de09f3968 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -2,7 +2,7 @@ * Styles that apply to all GFM related forms. */ -.gfm-commit, .gfm-commit_range { +.gfm-commit_range { font-family: $monospace_font; font-size: 90%; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 0c607071840..9823abdde1f 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -2,16 +2,6 @@ * Application Header * */ -@mixin tanuki-logo-colors($path-color) { - fill: $path-color; - transition: all 0.8s; - - &:hover, - &.highlight { - fill: lighten($path-color, 25%); - transition: all 0.1s; - } -} header { transition: padding $sidebar-transition-duration; @@ -25,7 +15,7 @@ header { margin: 8px 0; text-align: center; - #tanuki-logo, img { + .tanuki-logo, img { height: 36px; } } @@ -67,6 +57,10 @@ header { &:hover, &:focus, &:active { background-color: $background-color; } + + .fa-caret-down { + font-size: 15px; + } } .navbar-toggle { @@ -87,14 +81,10 @@ header { } } - &.header-collapsed { - padding: 0 16px; - } - .side-nav-toggle { position: absolute; left: -10px; - margin: 6px 0; + margin: 7px 0; font-size: 18px; padding: 6px 10px; border: none; @@ -126,11 +116,15 @@ header { .header-logo { position: absolute; left: 50%; - margin-left: -18px; top: 7px; transition-duration: .3s; z-index: 999; + #logo { + position: relative; + left: -50%; + } + svg, img { height: 36px; } @@ -140,12 +134,18 @@ header { } @media (max-width: $screen-xs-max) { - right: 25px; + right: 20px; left: auto; + + #logo { + left: auto; + } } } .title { + position: relative; + padding-right: 20px; margin: 0; font-size: 19px; max-width: 400px; @@ -158,7 +158,11 @@ header { vertical-align: top; white-space: nowrap; - @media (max-width: $screen-sm-max) { + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + max-width: 300px; + } + + @media (max-width: $screen-xs-max) { max-width: 190px; } @@ -170,11 +174,15 @@ header { } .dropdown-toggle-caret { - position: relative; - top: -2px; + color: $gl-text-color; + border: transparent; + background: transparent; + position: absolute; + right: 3px; width: 12px; - line-height: 12px; - margin-left: 5px; + line-height: 19px; + margin-top: (($header-height - 19) / 2); + padding: 0; font-size: 10px; text-align: center; cursor: pointer; @@ -205,26 +213,6 @@ header { } } -#tanuki-logo { - - #tanuki-left-ear, - #tanuki-right-ear, - #tanuki-nose { - @include tanuki-logo-colors($tanuki-red); - } - - #tanuki-left-eye, - #tanuki-right-eye { - @include tanuki-logo-colors($tanuki-orange); - } - - #tanuki-left-cheek, - #tanuki-right-cheek { - @include tanuki-logo-colors($tanuki-yellow); - } - -} - @media (max-width: $screen-xs-max) { header .container-fluid { font-size: 18px; diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 7cf4d4fba42..07c8874bf03 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -6,11 +6,11 @@ table-layout: fixed; pre { - padding: 10px; + padding: 10px 0; border: none; border-radius: 0; font-family: $monospace_font; - font-size: $code_font_size !important; + font-size: $code_font_size; line-height: $code_line_height !important; margin: 0; overflow: auto; @@ -20,13 +20,20 @@ border-left: 1px solid; code { + display: inline-block; + min-width: 100%; font-family: $monospace_font; - white-space: pre; + white-space: normal; word-wrap: normal; padding: 0; .line { - display: inline-block; + display: block; + width: 100%; + min-height: 19px; + padding-left: 10px; + padding-right: 10px; + white-space: pre; } } } diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 8bfc0d583c5..ba3930e03bd 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -16,7 +16,7 @@ margin-top: 5px; } - @include border-radius(3px); + border-radius: 3px; display: block; float: left; margin-right: 10px; diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 2c40ec430ca..efc348214c2 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -114,6 +114,12 @@ ul.content-list { font-size: $list-font-size; color: $list-text-color; + &.no-description { + .title { + line-height: $list-text-height; + } + } + .title { font-weight: 600; } @@ -134,12 +140,11 @@ ul.content-list { } .controls { - padding-top: 1px; float: right; > .control-text { margin-right: $gl-padding-top; - line-height: 40px; + line-height: $list-text-height; &:last-child { margin-right: 0; @@ -150,13 +155,17 @@ ul.content-list { > .btn-group { margin-right: $gl-padding-top; display: inline-block; - margin-top: 4px; + margin-top: 3px; margin-bottom: 4px; &:last-child { margin-right: 0; } } + + .no-comments { + opacity: .5; + } } // When dragging a list item diff --git a/app/assets/stylesheets/framework/logo.scss b/app/assets/stylesheets/framework/logo.scss new file mode 100644 index 00000000000..c214eabcad7 --- /dev/null +++ b/app/assets/stylesheets/framework/logo.scss @@ -0,0 +1,112 @@ +@mixin tanuki-logo-colors($path-color) { + fill: $path-color; + transition: all 0.8s; + + &:hover { + fill: lighten($path-color, 25%); + transition: all 0.1s; + } +} + +.tanuki-logo { + + .tanuki-left-ear, + .tanuki-right-ear, + .tanuki-nose { + @include tanuki-logo-colors($tanuki-red); + } + + .tanuki-left-eye, + .tanuki-right-eye { + @include tanuki-logo-colors($tanuki-orange); + } + + .tanuki-left-cheek, + .tanuki-right-cheek { + @include tanuki-logo-colors($tanuki-yellow); + } + + &.animate { + .tanuki-shape { + @include webkit-prefix(animation-duration, 1.5s); + @include webkit-prefix(animation-iteration-count, infinite); + } + + .tanuki-left-cheek { + @include include-keyframes(animate-tanuki-left-cheek) { + 0%, 10%, 100% { + fill: lighten($tanuki-yellow, 25%); + } + 90% { + fill: $tanuki-yellow; + } + } + } + + .tanuki-left-eye { + @include include-keyframes(animate-tanuki-left-eye) { + 10%, 80% { + fill: $tanuki-orange; + } + 20%, 90% { + fill: lighten($tanuki-orange, 25%); + } + } + } + + .tanuki-left-ear { + @include include-keyframes(animate-tanuki-left-ear) { + 10%, 80% { + fill: $tanuki-red; + } + 20%, 90% { + fill: lighten($tanuki-red, 25%); + } + } + } + + .tanuki-nose { + @include include-keyframes(animate-tanuki-nose) { + 20%, 70% { + fill: $tanuki-red; + } + 30%, 80% { + fill: lighten($tanuki-red, 25%); + } + } + } + + .tanuki-right-eye { + @include include-keyframes(animate-tanuki-right-eye) { + 30%, 60% { + fill: $tanuki-orange; + } + 40%, 70% { + fill: lighten($tanuki-orange, 25%); + } + } + } + + .tanuki-right-ear { + @include include-keyframes(animate-tanuki-right-ear) { + 30%, 60% { + fill: $tanuki-red; + } + 40%, 70% { + fill: lighten($tanuki-red, 25%); + } + } + } + + .tanuki-right-cheek { + @include include-keyframes(animate-tanuki-right-cheek) { + 40% { + fill: $tanuki-yellow; + } + 60% { + fill: lighten($tanuki-yellow, 25%); + } + } + } + } +} diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 96565da1bc9..6d28d98b283 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -86,7 +86,7 @@ } .markdown-area { - @include border-radius(0); + border-radius: 0; background: #fff; border: 1px solid #ddd; min-height: 140px; @@ -147,3 +147,8 @@ color: $gl-link-color; } } + +.atwho-view small.description { + float: right; + padding: 3px 5px; +} diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 5ec5a96a597..7fabf27a558 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -1,51 +1,8 @@ /** - * Generic mixins - */ -@mixin box-shadow($shadow) { - box-shadow: $shadow; -} - -@mixin border-radius($radius) { - border-radius: $radius; -} - -@mixin border-radius-left($radius) { - @include border-radius($radius 0 0 $radius) -} - -@mixin border-radius-right($radius) { - @include border-radius(0 0 $radius $radius) -} - -@mixin linear-gradient($from, $to) { - background-image: -webkit-gradient(linear, 0 0, 0 100%, from($from), to($to)); - background-image: -webkit-linear-gradient($from, $to); - background-image: -moz-linear-gradient($from, $to); - background-image: -ms-linear-gradient($from, $to); - background-image: -o-linear-gradient($from, $to); -} - -@mixin transition($transition) { - -webkit-transition: $transition; - -moz-transition: $transition; - -ms-transition: $transition; - -o-transition: $transition; - transition: $transition; -} - -/** * Prefilled mixins * Mixins with fixed values */ -@mixin shade { - @include box-shadow(0 0 3px #ddd); -} - -@mixin solid-shade { - @include box-shadow(0 0 0 3px #f1f1f1); -} - @mixin str-truncated($max_width: 82%) { display: inline-block; overflow: hidden; @@ -76,7 +33,7 @@ } &.active { - background: #f9f9f9; + background: $gray-light; a { font-weight: 600; } @@ -94,23 +51,6 @@ } } -@mixin input-big { - height: 36px; - padding: 5px 10px; - font-size: 16px; - line-height: 24px; - color: #7f8fa4; - background-color: #fff; - border-color: #e7e9ed; -} - -@mixin btn-big { - height: 36px; - padding: 5px 10px; - font-size: 16px; - line-height: 24px; -} - @mixin bulleted-list { > ul { list-style-type: disc; @@ -123,4 +63,31 @@ } } } -}
\ No newline at end of file +} + +@mixin dark-diff-match-line { + color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); +} + +@mixin webkit-prefix($property, $value) { + #{'-webkit-' + $property}: $value; + #{$property}: $value; +} + +@mixin keyframes($animation-name) { + @-webkit-keyframes #{$animation-name} { + @content; + } + + @keyframes #{$animation-name} { + @content; + } +} + +@mixin include-keyframes($animation-name) { + @include webkit-prefix(animation-name, $animation-name); + @include keyframes($animation-name) { + @content; + } +} diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 367c7d01944..9fe390eb09d 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -79,10 +79,6 @@ padding-left: 15px !important; } - .issue-info, .merge-request-info { - display: none; - } - .nav-links, .nav-links { li a { font-size: 14px; @@ -137,5 +133,5 @@ font-size: 20px; color: #777; z-index: 100; - @include box-shadow(0 1px 2px #ddd); + box-shadow: 0 1px 2px #ddd; } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 26ad2870aa0..8374f30d0b2 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -1,6 +1,5 @@ .modal-body { position: relative; - overflow-y: auto; padding: 15px; .form-actions { diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 7852fc9a424..ea43f4afc37 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -1,4 +1,4 @@ -@mixin fade($gradient-direction, $rgba, $gradient-color) { +@mixin fade($gradient-direction, $gradient-color) { visibility: hidden; opacity: 0; z-index: 2; @@ -8,10 +8,7 @@ height: 30px; transition-duration: .3s; -webkit-transform: translateZ(0); - background: -webkit-linear-gradient($gradient-direction, $rgba, $gradient-color 45%); - background: -o-linear-gradient($gradient-direction, $rgba, $gradient-color 45%); - background: -moz-linear-gradient($gradient-direction, $rgba, $gradient-color 45%); - background: linear-gradient($gradient-direction, $rgba, $gradient-color 45%); + background: linear-gradient(to $gradient-direction, $gradient-color 45%, rgba($gradient-color, 0.4)); &.scrolling { visibility: visible; @@ -71,7 +68,8 @@ .badge { font-weight: normal; background-color: #eee; - color: #78a; + color: $btn-transparent-color; + vertical-align: baseline; } } @@ -101,8 +99,7 @@ .top-area { @include clearfix; - - border-bottom: 1px solid #eee; + border-bottom: 1px solid $btn-gray-hover; .nav-text { padding-top: 16px; @@ -140,7 +137,7 @@ } li a { - padding: 16px 10px 11px; + padding: 16px 15px 11px; } /* Small devices (phones, tablets, 768px and lower) */ @@ -160,6 +157,7 @@ > .dropdown { margin-right: $gl-padding-top; display: inline-block; + vertical-align: top; &:last-child { margin-right: 0; @@ -209,12 +207,6 @@ } } - .project-filter-form { - input { - background-color: $background-color; - } - } - @media (max-width: $screen-xs-max) { padding-bottom: 0; width: 100%; @@ -334,10 +326,6 @@ } } - .badge { - color: $gl-icon-color; - } - &:hover { a, i { color: $black; @@ -355,7 +343,7 @@ } .fade-right { - @include fade(left, rgba(255, 255, 255, 0.4), $background-color); + @include fade(left, $background-color); right: -5px; .fa { @@ -364,7 +352,7 @@ } .fade-left { - @include fade(right, rgba(255, 255, 255, 0.4), $background-color); + @include fade(right, $background-color); left: -5px; .fa { @@ -375,6 +363,7 @@ &.sub-nav-scroll { .fade-right { + @include fade(left, $dark-background-color); right: 0; .fa { @@ -383,6 +372,7 @@ } .fade-left { + @include fade(right, $dark-background-color); left: 0; .fa { @@ -399,7 +389,7 @@ @include scrolling-links(); .fade-right { - @include fade(left, rgba(255, 255, 255, 0.4), $white-light); + @include fade(left, $white-light); right: -5px; .fa { @@ -408,7 +398,7 @@ } .fade-left { - @include fade(right, rgba(255, 255, 255, 0.4), $white-light); + @include fade(right, $white-light); left: -5px; .fa { diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index 874416e1007..c6f30e144fd 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -23,4 +23,9 @@ margin-top: $gl-padding; } } + + .panel-title { + font-size: inherit; + line-height: inherit; + } } diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 21d87cc9d34..79cd26714a3 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -21,7 +21,14 @@ padding-right: 10px; b { - @extend .caret; + display: inline-block; + width: 0; + height: 0; + margin-left: 2px; + vertical-align: middle; + border-top: $caret-width-base dashed; + border-right: $caret-width-base solid transparent; + border-left: $caret-width-base solid transparent; color: $gray-darkest; } } @@ -39,13 +46,14 @@ } .select2-drop { - @include box-shadow(rgba(76, 86, 103, 0.247059) 0 0 1px 0, rgba(31, 37, 50, 0.317647) 0 2px 18px 0); - @include border-radius ($border-radius-default); + box-shadow: rgba(76, 86, 103, 0.247059) 0 0 1px 0, rgba(31, 37, 50, 0.317647) 0 2px 18px 0; + border-radius: $border-radius-default; border: none; min-width: 175px; } -.select2-results .select2-result-label { +.select2-results .select2-result-label, +.select2-more-results { padding: 10px 15px; } @@ -64,7 +72,7 @@ .select2-container-active { .select2-choice, .select2-choices { - @include box-shadow(none); + box-shadow: none; } } @@ -74,13 +82,13 @@ outline: 0; background-image: none; background-color: $white-dark; - @include box-shadow($gl-btn-active-gradient); + box-shadow: $gl-btn-active-gradient; } } .select2-container-multi { .select2-choices { - @include border-radius($border-radius-default); + border-radius: $border-radius-default; border-color: $input-border; background: none; @@ -115,7 +123,7 @@ &.select2-container-active .select2-choices, &.select2-dropdown-open .select2-choices { border-color: $border-white-normal; - @include box-shadow($gl-btn-active-gradient); + box-shadow: $gl-btn-active-gradient; } } @@ -149,8 +157,8 @@ background-repeat: no-repeat; background-position: right 0 bottom 6px; border: 1px solid $input-border; - @include border-radius($border-radius-default); - @include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s); + border-radius: $border-radius-default; + transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; &:focus { border-color: $input-border-focus; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 3fa4a22258d..ec52f326eb9 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -1,11 +1,10 @@ .page-with-sidebar { - padding-top: $header-height; - padding-bottom: 25px; + padding: $header-height 0 25px; transition: padding $sidebar-transition-duration; &.page-sidebar-pinned { .sidebar-wrapper { - @include box-shadow(none); + box-shadow: none; } } @@ -15,9 +14,10 @@ bottom: 0; left: 0; height: 100%; + width: 0; overflow: hidden; transition: width $sidebar-transition-duration; - @include box-shadow(2px 0 16px 0 $black-transparent); + box-shadow: 2px 0 16px 0 $black-transparent; } } @@ -100,7 +100,7 @@ .count { float: right; padding: 0 8px; - @include border-radius(6px); + border-radius: 6px; } } @@ -128,10 +128,8 @@ .fa { transition: transform .15s; - } - &.is-active { - .fa { + .page-sidebar-pinned & { transform: rotate(90deg); } } @@ -144,6 +142,7 @@ transition-duration: .3s; position: absolute; top: 0; + cursor: pointer; &:hover, &:focus { @@ -152,14 +151,6 @@ } } -.page-sidebar-collapsed { - padding-left: 0; - - .sidebar-wrapper { - width: 0; - } -} - .page-sidebar-expanded { .sidebar-wrapper { width: $sidebar_width; @@ -175,7 +166,7 @@ } } -header.header-pinned-nav { +header.header-sidebar-pinned { @media (min-width: $sidebar-breakpoint) { padding-left: ($sidebar_width + $gl-padding); @@ -222,3 +213,7 @@ header.header-pinned-nav { padding-right: $sidebar_collapsed_width; } } + +.right-sidebar { + border-left: 1px solid $border-color; +} diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss index 371c1bf17e1..915aa631ef8 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss @@ -125,7 +125,7 @@ $panel-inner-border: $border-color; // //## -$well-bg: #f9f9f9; +$well-bg: $gray-light; $well-border: #eee; //== Code diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 8659604cb8b..d099a884f54 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -14,12 +14,20 @@ margin-top: 0; } + // Single code lines should wrap code { font-family: $monospace_font; - white-space: pre; + white-space: pre-wrap; word-wrap: normal; } + // Multi-line code blocks should scroll horizontally + pre { + code { + white-space: pre; + } + } + kbd { display: inline-block; padding: 3px 5px; @@ -108,7 +116,7 @@ font-size: 13px; line-height: 1.6em; overflow-x: auto; - @include border-radius(2px); + border-radius: 2px; } p > code { @@ -151,25 +159,18 @@ position: relative; a.anchor { - // Setting `display: none` would prevent the anchor being scrolled to, so - // instead we set the height to 0 and it gets updated on hover. - height: 0; + left: -16px; + position: absolute; + text-decoration: none; + + &:after { + content: image-url('icon_anchor.svg'); + visibility: hidden; + } } - &:hover > a.anchor { - $size: 14px; - position: absolute; - right: 100%; - top: 50%; - margin-top: -11px; - margin-right: 0; - padding-right: 15px; - display: inline-block; - width: $size; - height: $size; - background-image: image-url("icon-link.png"); - background-size: contain; - background-repeat: no-repeat; + &:hover > a.anchor:after { + visibility: visible; } } } @@ -203,7 +204,7 @@ body { } h1, h2, h3, h4, h5, h6 { - color: $gl-header-color; + color: $gl-title-color; font-weight: 600; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1882d4e888d..4c34ed3ebf7 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -10,12 +10,82 @@ $sidebar-transition-duration: .15s; $sidebar-breakpoint: 1024px; /* + * Color schema + */ +$white-light: #fff; +$white-normal: #ededed; +$white-dark: #ececec; + +$gray-light: #fafafa; +$gray-lighter: #f9f9f9; +$gray-normal: #f5f5f5; +$gray-dark: #ededed; +$gray-darker: #eee; +$gray-darkest: #c9c9c9; + +$green-light: #38ae67; +$green-normal: #2faa60; +$green-dark: #2ca05b; + +$blue-light: #2ea8e5; +$blue-normal: #2d9fd8; +$blue-dark: #2897ce; + +$blue-medium-light: #3498cb; +$blue-medium: #2f8ebf; +$blue-medium-dark: #2d86b4; + +$blue-light-transparent: rgba(44, 159, 216, 0.05); + +$orange-light: #fc8a51; +$orange-normal: #e75e40; +$orange-dark: #ce5237; + +$red-light: #e52c5a; +$red-normal: #d22852; +$red-dark: darken($red-normal, 5%); + +$black: #000; +$black-transparent: rgba(0, 0, 0, 0.3); + +$border-white-light: #f1f2f4; +$border-white-normal: #d6dae2; +$border-white-dark: #c6cacf; + +$border-gray-light: #dcdcdc; +$border-gray-normal: #d7d7d7; +$border-gray-dark: #c6cacf; + +$border-green-light: #2faa60; +$border-green-normal: #2ca05b; +$border-green-dark: #279654; + +$border-blue-light: #2d9fd8; +$border-blue-normal: #2897ce; +$border-blue-dark: #258dc1; + +$border-orange-light: #fc6d26; +$border-orange-normal: #ce5237; +$border-orange-dark: #c14e35; + +$border-red-light: #d22852; +$border-red-normal: #ca264f; +$border-red-dark: darken($border-red-normal, 5%); + +$help-well-bg: $gray-light; +$help-well-border: #e5e5e5; + +$warning-message-bg: #fbf2d9; +$warning-message-color: #9e8e60; +$warning-message-border: #f0e2bb; + +/* * UI elements */ $border-color: #e5e5e5; $focus-border-color: #3aabf0; $table-border-color: #f0f0f0; -$background-color: #fafafa; +$background-color: $gray-light; $dark-background-color: #f5f5f5; $table-text-gray: #8f8f8f; @@ -25,6 +95,7 @@ $table-text-gray: #8f8f8f; $gl-font-size: 15px; $gl-title-color: #333; $gl-text-color: #5c5c5c; +$gl-text-color-light: #8c8c8c; $gl-text-green: #4a2; $gl-text-red: #d12f19; $gl-text-orange: #d90; @@ -35,7 +106,8 @@ $gl-icon-color: $gl-placeholder-color; $gl-grayish-blue: #7f8fa4; $gl-gray: $gl-text-color; $gl-gray-dark: #313236; -$gl-header-color: $gl-title-color; +$gl-gray-light: $gl-placeholder-color; +$gl-header-color: #4c4e54; /* * Lists @@ -43,6 +115,7 @@ $gl-header-color: $gl-title-color; $list-font-size: $gl-font-size; $list-title-color: $gl-title-color; $list-text-color: $gl-text-color; +$list-text-height: 42px; /* * Markdown @@ -89,73 +162,6 @@ $btn-side-margin: 10px; $btn-sm-side-margin: 7px; $btn-xs-side-margin: 5px; -/* - * Color schema - */ - -$white-light: #fff; -$white-normal: #ededed; -$white-dark: #ececec; - -$gray-light: #faf9f9; -$gray-normal: #f5f5f5; -$gray-dark: #ededed; -$gray-darkest: #c9c9c9; - -$green-light: #38ae67; -$green-normal: #2faa60; -$green-dark: #2ca05b; - -$blue-light: #2ea8e5; -$blue-normal: #2d9fd8; -$blue-dark: #2897ce; - -$blue-medium-light: #3498cb; -$blue-medium: #2f8ebf; -$blue-medium-dark: #2d86b4; - -$orange-light: #fc8a51; -$orange-normal: #e75e40; -$orange-dark: #ce5237; - -$red-light: #e52c5a; -$red-normal: #d22852; -$red-dark: darken($red-normal, 5%); - -$black: #000; -$black-transparent: rgba(0, 0, 0, 0.3); - -$border-white-light: #f1f2f4; -$border-white-normal: #d6dae2; -$border-white-dark: #c6cacf; - -$border-gray-light: #dcdcdc; -$border-gray-normal: #d7d7d7; -$border-gray-dark: #c6cacf; - -$border-green-light: #2faa60; -$border-green-normal: #2ca05b; -$border-green-dark: #279654; - -$border-blue-light: #2d9fd8; -$border-blue-normal: #2897ce; -$border-blue-dark: #258dc1; - -$border-orange-light: #fc6d26; -$border-orange-normal: #ce5237; -$border-orange-dark: #c14e35; - -$border-red-light: #d22852; -$border-red-normal: #ca264f; -$border-red-dark: darken($border-red-normal, 5%); - -$help-well-bg: #fafafa; -$help-well-border: #e5e5e5; - -$warning-message-bg: #fbf2d9; -$warning-message-color: #9e8e60; -$warning-message-border: #f0e2bb; - /* tanuki logo colors */ $tanuki-red: #e24329; $tanuki-orange: #fc6d26; @@ -185,7 +191,7 @@ $line-removed-dark: #fac5cd; $line-number-old: #f9d7dc; $line-number-new: #ddfbe6; $line-number-select: #fbf2da; -$match-line: #fafafa; +$match-line: $gray-light; $table-border-gray: #f0f0f0; $line-target-blue: #eaf3fc; $line-select-yellow: #fcf8e7; @@ -266,7 +272,13 @@ $zen-control-hover-color: #111; $calendar-header-color: #b8b8b8; $calendar-hover-bg: #ecf3fe; $calendar-border-color: rgba(#000, .1); -$calendar-unselectable-bg: #faf9f9; +$calendar-unselectable-bg: $gray-light; + +/* + * Cycle Analytics + */ +$cycle-analytics-box-padding: 30px; +$cycle-analytics-box-text-color: #8c8c8c; /* * Personal Access Tokens @@ -275,3 +287,5 @@ $personal-access-tokens-disabled-label-color: #bbb; $ci-output-bg: #1d1f21; $ci-text-color: #c5c8c6; + +$issue-boards-font-size: 15px; diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 77a73dc379b..16ffbe57a99 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #557; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index 80a509a7c1a..7de920e074b 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #49483e; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index c62bd021aef..b11499c71ee 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -21,6 +21,10 @@ // Diff line .line_holder { + &.match .line_content { + @include dark-diff-match-line; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #174652; @@ -36,8 +40,7 @@ } .line_content.match { - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.1); + @include dark-diff-match-line; } } diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 524cfaf90c3..657bb5e3cd9 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -1,4 +1,10 @@ /* https://gist.github.com/qguv/7936275 */ + +@mixin matchLine { + color: $black-transparent; + background: rgba(255, 255, 255, 0.4); +} + .code.solarized-light { // Line numbers .line-numbers, .diff-line-num { @@ -21,6 +27,10 @@ // Diff line .line_holder { + &.match .line_content { + @include matchLine; + } + td.diff-line-num.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { background-color: #ddd8c5; @@ -36,8 +46,7 @@ } .line_content.match { - color: $black-transparent; - background: rgba(255, 255, 255, 0.4); + @include matchLine; } } diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 31a4e3deaac..36a80a916b2 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -1,4 +1,10 @@ /* https://github.com/aahan/pygments-github-style */ + +@mixin matchLine { + color: $black-transparent; + background-color: $match-line; +} + .code.white { // Line numbers .line-numbers, .diff-line-num { @@ -22,6 +28,10 @@ // Diff line .line_holder { + &.match .line_content { + @include matchLine; + } + .diff-line-num { &.old { background-color: $line-number-old; @@ -57,8 +67,7 @@ } &.match { - color: $black-transparent; - background-color: $match-line; + @include matchLine; } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/mailers/repository_push_email.scss b/app/assets/stylesheets/mailers/repository_push_email.scss index 33aedf1f7c1..5bfe9bcb443 100644 --- a/app/assets/stylesheets/mailers/repository_push_email.scss +++ b/app/assets/stylesheets/mailers/repository_push_email.scss @@ -45,7 +45,6 @@ .line_content { padding-left: 0.5em; padding-right: 0.5em; - white-space: pre; &.old { background-color: $line-removed; @@ -71,6 +70,10 @@ } } +pre { + margin: 0; +} + span.highlight_word { background-color: #fafe3d !important; } diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss index 5607239d92d..8f71381f5c4 100644 --- a/app/assets/stylesheets/pages/admin.scss +++ b/app/assets/stylesheets/pages/admin.scss @@ -72,7 +72,6 @@ margin-bottom: 20px; } - // Users List .users-list { @@ -97,4 +96,49 @@ line-height: inherit; } } + + .label-default { + color: $btn-transparent-color; + } +} + +.abuse-reports { + .table { + table-layout: fixed; + } + .subheading { + padding-bottom: $gl-padding; + } + .message { + word-wrap: break-word; + } + .btn { + white-space: normal; + padding: $gl-btn-padding; + } + th { + width: 15%; + &.wide { + width: 55%; + } + } + @media (max-width: $screen-sm-max) { + th { + width: 100%; + } + td { + width: 100%; + float: left; + } + } + + .no-reports { + .emoji-icon { + margin-left: $btn-side-margin; + margin-top: 3px; + } + span { + font-size: 19px; + } + } } diff --git a/app/assets/stylesheets/pages/awards.scss b/app/assets/stylesheets/pages/awards.scss index 5faedfedd66..9282e0ae03b 100644 --- a/app/assets/stylesheets/pages/awards.scss +++ b/app/assets/stylesheets/pages/awards.scss @@ -93,11 +93,8 @@ } .award-control { - margin-right: 5px; - margin-bottom: 5px; - padding-left: 5px; - padding-right: 5px; - line-height: 20px; + margin: 3px 5px 3px 0; + padding: 6px 5px; outline: 0; &:hover, diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss new file mode 100644 index 00000000000..6e81c12aa55 --- /dev/null +++ b/app/assets/stylesheets/pages/boards.scss @@ -0,0 +1,267 @@ +lex +[v-cloak] { + display: none; +} + +.user-can-drag { + cursor: -webkit-grab; + cursor: grab; +} + +.is-dragging { + // Important because plugin sets inline CSS + opacity: 1!important; + + * { + // !important to make sure no style can override this when dragging + cursor: -webkit-grabbing!important; + cursor: grabbing!important; + } +} + +.is-ghost { + opacity: 0.3; +} + +.dropdown-menu-issues-board-new { + width: 320px; + + .dropdown-content { + max-height: 150px; + } +} + +.issue-board-dropdown-content { + margin: 0 8px 10px; + padding-bottom: 10px; + border-bottom: 1px solid $dropdown-divider-color; + + > p { + margin: 0; + font-size: 14px; + } +} + +.issue-boards-page { + .page-with-sidebar { + padding-bottom: 0; + } +} + +.boards-app-loading { + width: 100%; + font-size: 34px; +} + +.boards-list { + height: calc(100vh - 152px); + width: 100%; + padding-top: 25px; + padding-bottom: 25px; + padding-right: ($gl-padding / 2); + padding-left: ($gl-padding / 2); + overflow-x: scroll; + white-space: nowrap; + + @media (min-width: $screen-sm-min) { + height: 475px; // Needed for PhantomJS + height: calc(100vh - 220px); + min-height: 475px; + } +} + +.board { + display: inline-block; + width: calc(85vw - 15px); + height: 100%; + padding-right: ($gl-padding / 2); + padding-left: ($gl-padding / 2); + white-space: normal; + vertical-align: top; + + @media (min-width: $screen-sm-min) { + width: 400px; + } +} + +.board-inner { + height: 100%; + font-size: $issue-boards-font-size; + background: $background-color; + border: 1px solid $border-color; + border-radius: $border-radius-default; +} + +.board-header { + border-top-left-radius: $border-radius-default; + border-top-right-radius: $border-radius-default; + + &.has-border { + border-top: 3px solid; + + .board-title { + padding-top: ($gl-padding - 3px); + } + } +} + +.board-inner-container { + border-bottom: 1px solid $border-color; + padding: $gl-padding; +} + +.board-title { + position: relative; + margin: 0; + padding: $gl-padding; + font-size: 1em; + border-bottom: 1px solid $border-color; +} + +.board-delete { + margin-right: 10px; + padding: 0; + color: $gray-darkest; + background-color: transparent; + border: 0; + outline: 0; + + &:hover { + color: $gl-link-color; + } +} + +.board-blank-state { + height: 100%; + padding: $gl-padding; + background-color: #fff; +} + +.board-blank-state-list { + list-style: none; + + > li:not(:last-child) { + margin-bottom: 8px; + } + + .label-color { + position: relative; + top: 2px; + display: inline-block; + width: 16px; + height: 16px; + margin-right: 3px; + border-radius: $border-radius-default; + } +} + +.board-list { + height: calc(100% - 49px); + margin-bottom: 0; + padding: 5px; + list-style: none; + overflow-y: scroll; + overflow-x: hidden; + + &.is-smaller { + height: calc(100% - 185px); + } +} + +.board-list-loading { + margin-top: 10px; + font-size: (26px / $issue-boards-font-size) * 1em; +} + +.card { + position: relative; + padding: 10px $gl-padding; + background: #fff; + border-radius: $border-radius-default; + box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5); + list-style: none; + + &:not(:last-child) { + margin-bottom: 5px; + } + + .label { + border: 0; + outline: 0; + } + + .confidential-icon { + margin-right: 5px; + } +} + +.card-title { + margin: 0; + font-size: 1em; + + a { + color: inherit; + word-wrap: break-word; + } +} + +.card-footer { + margin-top: 5px; + line-height: 25px; + + .label { + margin-right: 5px; + font-size: (14px / $issue-boards-font-size) * 1em; + } +} + +.card-number { + margin-right: 5px; +} + +.issue-boards-search { + width: 335px; + + .form-control { + display: inline-block; + width: 210px; + } +} + +.board-list-count { + padding: 10px 0; + color: $gl-placeholder-color; + font-size: 13px; + + > .fa { + margin-right: 5px; + } +} + +.board-new-issue-form { + margin: 5px; +} + +.board-issue-count-holder { + margin-top: -3px; + + .btn { + line-height: 12px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + +.board-issue-count { + padding-right: 10px; + padding-left: 10px; + line-height: 21px; + border-radius: $border-radius-base; + border: 1px solid $border-color; + + &.has-btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-width: 1px 0 1px 1px; + } +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index e26f8f7080d..194a39a8377 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -36,6 +36,7 @@ &.affix { right: 30px; bottom: 15px; + z-index: 1; @media (min-width: $screen-md-min) { right: 26%; @@ -47,20 +48,6 @@ margin-bottom: 10px; } } - - .page-sidebar-collapsed { - .scroll-controls { - left: 70px; - } - } - - .nav-links { - svg { - position: relative; - top: 2px; - margin-right: 3px; - } - } } .build-header { @@ -108,28 +95,132 @@ } .right-sidebar.build-sidebar { - padding-top: $gl-padding; - padding-bottom: $gl-padding; + padding: $gl-padding 0; &.right-sidebar-collapsed { display: none; } + .blocks-container { + padding: 0 $gl-padding; + } + .block { width: 100%; + + &.coverage { + padding: 0 16px 11px; + } + + .btn-group-justified { + margin-top: 5px; + } + } + + .js-build-variable { + color: $code-color; + } + + .js-build-value { + padding: 2px 4px; + color: $black; + background-color: $white-light; } .build-sidebar-header { - padding-top: 0; + padding: 0 $gl-padding $gl-padding; .gutter-toggle { margin-top: 0; } } + + .retry-link { + color: $gl-link-color; + &:hover { + text-decoration: underline; + } + } + + .stage-item { + cursor: pointer; + + &:hover { + color: $gl-text-color; + } + } + + .build-dropdown { + padding: $gl-padding 0; + + .dropdown-menu-toggle { + margin-top: 8px; + } + + .dropdown-menu { + right: $gl-padding; + left: $gl-padding; + width: auto; + } + } + + .builds-container { + background-color: $white-light; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + max-height: 300px; + overflow: auto; + + svg { + position: relative; + top: 2px; + margin-right: 3px; + height: 13px; + } + + a { + display: block; + padding: $gl-padding 10px $gl-padding 40px; + width: 270px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + color: $gl-text-color; + } + } + + .build-job { + position: relative; + + .fa { + position: absolute; + left: 15px; + top: 20px; + display: none; + } + + &.active { + font-weight: bold; + + .fa { + display: block; + } + } + + &:hover { + background-color: $row-hover; + } + } + } } .build-detail-row { margin-bottom: 5px; + &:last-of-type { + margin-bottom: 0; + } } .build-light-text { diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index bbe0c6c5f1f..53ec0002afe 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -66,6 +66,15 @@ margin-left: 8px; } } + + .ci-status-link { + + svg { + position: relative; + top: 2px; + margin: 0 2px 0 3px; + } + } } .ci-status-link { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 6a58b445afa..dc57a837155 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -18,8 +18,7 @@ } .commit-row-title { - line-height: 1; - margin-bottom: 7px; + line-height: 1.35; .notes_count { float: right; @@ -43,6 +42,7 @@ border: 1px solid $border-gray-dark; border-radius: $border-radius-default; margin-left: 5px; + line-height: 1; &:hover { background-color: darken($gray-light, 10%); @@ -113,11 +113,13 @@ .commit-row-description { font-size: 14px; - border-left: 1px solid #eee; + border-left: 1px solid $btn-gray-hover; padding: 10px 15px; margin: 10px 0; - background: #f9f9f9; + background: $gray-light; display: none; + white-space: pre-line; + word-break: normal; pre { border: none; @@ -134,7 +136,7 @@ .commit-row-info { color: $gl-gray; - line-height: 1; + line-height: 1.35; a { color: $gl-gray; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss new file mode 100644 index 00000000000..d732008de3d --- /dev/null +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -0,0 +1,144 @@ +#cycle-analytics { + margin: 24px auto 0; + max-width: 800px; + position: relative; + + .panel { + + .content-block { + padding: 24px 0; + border-bottom: none; + position: relative; + + @media (max-width: $screen-sm-min) { + padding: 6px 0 24px; + } + } + + .column { + text-align: center; + + @media (max-width: $screen-sm-min) { + padding: 15px 0; + } + + .header { + font-size: 30px; + line-height: 38px; + font-weight: normal; + margin: 0; + } + + .text { + color: $layout-link-gray; + margin: 0; + } + + &:last-child { + text-align: right; + + @media (max-width: $screen-sm-min) { + text-align: center; + } + } + } + + .dropdown { + top: 13px; + } + } + + .bordered-box { + border: 1px solid $border-color; + border-radius: $border-radius-default; + + } + + .content-list { + li { + padding: 18px $gl-padding $gl-padding; + + .container-fluid { + padding: 0; + } + } + + .title-col { + p { + margin: 0; + + &.title { + line-height: 19px; + font-size: 15px; + font-weight: 600; + color: $gl-title-color; + } + + &.text { + color: $layout-link-gray; + + &.value-col { + color: $gl-title-color; + } + } + } + } + + .value-col { + text-align: right; + + span { + position: relative; + vertical-align: middle; + top: 3px; + } + } + } + + .landing { + margin-bottom: $gl-padding; + overflow: hidden; + + .dismiss-icon { + position: absolute; + right: $cycle-analytics-box-padding; + cursor: pointer; + color: #b2b2b2; + } + + .svg-container { + text-align: center; + + svg { + width: 136px; + height: 136px; + } + } + + .inner-content { + @media (max-width: $screen-sm-min) { + padding: 0 28px; + text-align: center; + } + + h4 { + color: $gl-text-color; + font-size: 17px; + } + + p { + color: $cycle-analytics-box-text-color; + margin-bottom: $gl-padding; + } + } + } + + .fa-spinner { + font-size: 28px; + position: relative; + margin-left: -20px; + left: 50%; + margin-top: 36px; + } + +} diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 1b389d83525..4d9c73c6840 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -34,11 +34,4 @@ } } } - - .wiki { - code { - white-space: pre-wrap; - word-break: keep-all; - } - } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 21cee2e3a70..b8ef76cc74e 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -68,6 +68,11 @@ border-collapse: separate; margin: 0; padding: 0; + table-layout: fixed; + + .diff-line-num { + width: 50px; + } .line_holder td { line-height: $code_line_height; @@ -98,10 +103,6 @@ } tr.line_holder.parallel { - .old_line, .new_line { - min-width: 50px; - } - td.line_content.parallel { width: 46%; } diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 1aa4e06d975..fcc5f32c738 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -1,7 +1,7 @@ .file-editor { #editor { border: none; - @include border-radius(0); + border-radius: 0; height: 500px; margin: 0; padding: 0; @@ -59,6 +59,7 @@ } .encoding-selector, + .soft-wrap-toggle, .license-selector, .gitignore-selector, .gitlab-ci-yml-selector { @@ -67,6 +68,24 @@ font-family: $regular_font; } + .soft-wrap-toggle { + margin: 0 $btn-side-margin; + .soft-wrap { + display: block; + } + .no-wrap { + display: none; + } + &.soft-wrap-active { + .soft-wrap { + display: none; + } + .no-wrap { + display: block; + } + } + } + .gitignore-selector, .license-selector, .gitlab-ci-yml-selector { .dropdown { line-height: 21px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index e160d676e35..3f19e920166 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -1,5 +1,67 @@ +.environments-container, +.deployments-container { + width: 100%; + overflow: auto; +} + .environments { + .deployment-column { + .avatar { + float: none; + } + } + .commit-title { margin: 0; } + + .icon-play { + height: 13px; + width: 12px; + } + + .external-url, + .dropdown-new { + color: $table-text-gray; + } + + .dropdown-menu { + + .fa { + margin-right: 6px; + color: $table-text-gray; + } + } + + .build-link, + .branch-name { + color: $gl-dark-link-color; + } + + .deployment { + .build-column { + + .build-link { + color: $gl-dark-link-color; + } + + .avatar { + float: none; + } + } + } +} + +.table.builds.environments { + + .icon-container { + width: 20px; + text-align: center; + } + + .branch-commit { + .commit-id { + margin-right: 0; + } + } } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 5c336bb1c7e..789d6237df8 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -60,7 +60,7 @@ pre { border: none; - background: #f9f9f9; + background: $gray-light; border-radius: 0; color: #777; margin: 0 20px; @@ -91,8 +91,8 @@ float: right; border: 1px solid #eee; padding: 5px; - @include border-radius(5px); - background: #f9f9f9; + border-radius: 5px; + background: $gray-light; margin-left: 10px; top: -6px; img { @@ -115,11 +115,8 @@ } &.commits-stat { - margin-top: 3px; display: block; - padding: 3px; - padding-left: 0; - + padding: 0 3px 0 0; &:hover { background: none; } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 2a3acc3eb4c..185ce970e71 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -23,15 +23,9 @@ } .group-row { - &.no-description { - .group-name { - line-height: 44px; - } - } - .stats { float: right; - line-height: 44px; + line-height: $list-text-height; color: $gl-gray; span { @@ -61,3 +55,50 @@ } } } + +.groups-header { + @media (min-width: $screen-sm-min) { + .nav-links { + width: 35%; + } + + .nav-controls { + width: 65%; + } + } +} + +.groups-empty-state { + padding: 50px 100px; + overflow: hidden; + + @media (max-width: $screen-md-min) { + padding: 50px 0; + } + + svg { + float: right; + + @media (max-width: $screen-md-min) { + float: none; + display: block; + width: 250px; + position: relative; + left: 50%; + margin-left: -125px; + } + } + + .text-content { + float: left; + width: 460px; + margin-top: 120px; + + @media (max-width: $screen-md-min) { + float: none; + margin-top: 60px; + width: auto; + text-align: center; + } + } +} diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index 84cc35239f9..a4f76a9495a 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -1,22 +1,3 @@ -i.icon-gitorious { - display: inline-block; - background-position: 0 0; - background-size: contain; - background-repeat: no-repeat; -} - -i.icon-gitorious-small { - background-image: image-url('gitorious-logo-blue.png'); - width: 13px; - height: 13px; -} - -i.icon-gitorious-big { - background-image: image-url('gitorious-logo-black.png'); - width: 18px; - height: 18px; -} - .import-jobs-from-col, .import-jobs-to-col { width: 40%; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7a50bc9c832..41079b6eeb5 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -206,7 +206,7 @@ padding-top: 0; .block { - width: $sidebar_collapsed_width - 1px; + width: $sidebar_collapsed_width - 2px; margin-left: -19px; padding: 15px 0 0; border-bottom: none; @@ -395,3 +395,27 @@ display: inline-block; line-height: 18px; } + +.js-issuable-selector-wrap { + .js-issuable-selector { + width: 100%; + } + @media (max-width: $screen-sm-max) { + margin-bottom: $gl-padding; + } +} + +.issuable-list { + li { + .issue-check { + float: left; + padding-right: $gl-padding; + margin-bottom: 10px; + min-width: 15px; + + .selected_issue { + vertical-align: text-top; + } + } + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index dfe1e3075da..3ac34cbc829 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -7,20 +7,9 @@ margin-bottom: 2px; } - .issue-check { - float: left; - padding-right: 8px; - margin-bottom: 10px; - min-width: 15px; - } - .issue-labels { display: inline-block; } - - .issue-no-comments { - opacity: 0.5; - } } } @@ -44,6 +33,15 @@ form.edit-issue { margin: 0; } +ul.related-merge-requests > li { + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + .merge-request-id { + flex-shrink: 0; + } +} + .merge-requests-title, .related-branches-title { font-size: 16px; font-weight: 600; @@ -68,12 +66,12 @@ form.edit-issue { } &.closed { - background: #f9f9f9; + background: $gray-light; border-color: #e5e5e5; } &.merged { - background: #f9f9f9; + background: $gray-light; border-color: #e5e5e5; } } diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 3b1e38fc07d..701c29a3986 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -1,12 +1,13 @@ .suggest-colors { margin-top: 5px; a { - @include border-radius(4px); + border-radius: 4px; width: 30px; height: 30px; display: inline-block; margin-right: 10px; margin-bottom: 10px; + text-decoration: none; } &.suggest-colors-dropdown { @@ -16,7 +17,7 @@ overflow: hidden; a { - @include border-radius(0); + border-radius: 0; width: (100% / 7); margin-right: 0; margin-bottom: -5px; @@ -58,6 +59,13 @@ width: 200px; margin-bottom: 0; } + + .label { + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + max-width: 100%; + } } .label-description { @@ -182,6 +190,17 @@ .btn { color: inherit; } + + a.btn { + padding: 0; + + .has-tooltip { + top: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + line-height: 1.1; + } + } } .label-options-toggle { diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 403171d4532..a5ca509163d 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -73,12 +73,12 @@ height: auto; &.top { - @include border-radius(5px 5px 0 0); + border-radius: 5px 5px 0 0; margin-bottom: 0; } &.bottom { - @include border-radius(0 0 5px 5px); + border-radius: 0 0 5px 5px; border-top: 0; margin-bottom: 20px; } @@ -86,7 +86,7 @@ &.middle { border-top: 0; margin-bottom: 0; - @include border-radius(0); + border-radius: 0; } &:active, &:focus { diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss new file mode 100644 index 00000000000..5ec660799e3 --- /dev/null +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -0,0 +1,238 @@ +$colors: ( + white_header_head_neutral : #e1fad7, + white_line_head_neutral : #effdec, + white_button_head_neutral : #9adb84, + + white_header_head_chosen : #baf0a8, + white_line_head_chosen : #e1fad7, + white_button_head_chosen : #52c22d, + + white_header_origin_neutral : #e0f0ff, + white_line_origin_neutral : #f2f9ff, + white_button_origin_neutral : #87c2fa, + + white_header_origin_chosen : #add8ff, + white_line_origin_chosen : #e0f0ff, + white_button_origin_chosen : #268ced, + + white_header_not_chosen : #f0f0f0, + white_line_not_chosen : $gray-light, + + + dark_header_head_neutral : rgba(#3f3, .2), + dark_line_head_neutral : rgba(#3f3, .1), + dark_button_head_neutral : #40874f, + + dark_header_head_chosen : rgba(#3f3, .33), + dark_line_head_chosen : rgba(#3f3, .2), + dark_button_head_chosen : #258537, + + dark_header_origin_neutral : rgba(#2878c9, .4), + dark_line_origin_neutral : rgba(#2878c9, .3), + dark_button_origin_neutral : #2a5c8c, + + dark_header_origin_chosen : rgba(#2878c9, .6), + dark_line_origin_chosen : rgba(#2878c9, .4), + dark_button_origin_chosen : #1d6cbf, + + dark_header_not_chosen : rgba(#fff, .25), + dark_line_not_chosen : rgba(#fff, .1), + + + monokai_header_head_neutral : rgba(#a6e22e, .25), + monokai_line_head_neutral : rgba(#a6e22e, .1), + monokai_button_head_neutral : #376b20, + + monokai_header_head_chosen : rgba(#a6e22e, .4), + monokai_line_head_chosen : rgba(#a6e22e, .25), + monokai_button_head_chosen : #39800d, + + monokai_header_origin_neutral : rgba(#60d9f1, .35), + monokai_line_origin_neutral : rgba(#60d9f1, .15), + monokai_button_origin_neutral : #38848c, + + monokai_header_origin_chosen : rgba(#60d9f1, .5), + monokai_line_origin_chosen : rgba(#60d9f1, .35), + monokai_button_origin_chosen : #3ea4b2, + + monokai_header_not_chosen : rgba(#76715d, .24), + monokai_line_not_chosen : rgba(#76715d, .1), + + + solarized_light_header_head_neutral : rgba(#859900, .37), + solarized_light_line_head_neutral : rgba(#859900, .2), + solarized_light_button_head_neutral : #afb262, + + solarized_light_header_head_chosen : rgba(#859900, .5), + solarized_light_line_head_chosen : rgba(#859900, .37), + solarized_light_button_head_chosen : #94993d, + + solarized_light_header_origin_neutral : rgba(#2878c9, .37), + solarized_light_line_origin_neutral : rgba(#2878c9, .15), + solarized_light_button_origin_neutral : #60a1bf, + + solarized_light_header_origin_chosen : rgba(#2878c9, .6), + solarized_light_line_origin_chosen : rgba(#2878c9, .37), + solarized_light_button_origin_chosen : #2482b2, + + solarized_light_header_not_chosen : rgba(#839496, .37), + solarized_light_line_not_chosen : rgba(#839496, .2), + + + solarized_dark_header_head_neutral : rgba(#859900, .35), + solarized_dark_line_head_neutral : rgba(#859900, .15), + solarized_dark_button_head_neutral : #376b20, + + solarized_dark_header_head_chosen : rgba(#859900, .5), + solarized_dark_line_head_chosen : rgba(#859900, .35), + solarized_dark_button_head_chosen : #39800d, + + solarized_dark_header_origin_neutral : rgba(#2878c9, .35), + solarized_dark_line_origin_neutral : rgba(#2878c9, .15), + solarized_dark_button_origin_neutral : #086799, + + solarized_dark_header_origin_chosen : rgba(#2878c9, .6), + solarized_dark_line_origin_chosen : rgba(#2878c9, .35), + solarized_dark_button_origin_chosen : #0082cc, + + solarized_dark_header_not_chosen : rgba(#839496, .25), + solarized_dark_line_not_chosen : rgba(#839496, .15) +); + + +@mixin color-scheme($color) { + .header.line_content, .diff-line-num { + &.origin { + background-color: map-get($colors, #{$color}_header_origin_neutral); + border-color: map-get($colors, #{$color}_header_origin_neutral); + + button { + background-color: map-get($colors, #{$color}_button_origin_neutral); + border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15); + } + + &.selected { + background-color: map-get($colors, #{$color}_header_origin_chosen); + border-color: map-get($colors, #{$color}_header_origin_chosen); + + button { + background-color: map-get($colors, #{$color}_button_origin_chosen); + border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15); + } + } + + &.unselected { + background-color: map-get($colors, #{$color}_header_not_chosen); + border-color: map-get($colors, #{$color}_header_not_chosen); + + button { + background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15); + border-color: map-get($colors, #{$color}_button_origin_neutral); + } + } + } + &.head { + background-color: map-get($colors, #{$color}_header_head_neutral); + border-color: map-get($colors, #{$color}_header_head_neutral); + + button { + background-color: map-get($colors, #{$color}_button_head_neutral); + border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15); + } + + &.selected { + background-color: map-get($colors, #{$color}_header_head_chosen); + border-color: map-get($colors, #{$color}_header_head_chosen); + + button { + background-color: map-get($colors, #{$color}_button_head_chosen); + border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15); + } + } + + &.unselected { + background-color: map-get($colors, #{$color}_header_not_chosen); + border-color: map-get($colors, #{$color}_header_not_chosen); + + button { + background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15); + border-color: map-get($colors, #{$color}_button_head_neutral); + } + } + } + } + + .line_content { + &.origin { + background-color: map-get($colors, #{$color}_line_origin_neutral); + + &.selected { + background-color: map-get($colors, #{$color}_line_origin_chosen); + } + + &.unselected { + background-color: map-get($colors, #{$color}_line_not_chosen); + } + } + &.head { + background-color: map-get($colors, #{$color}_line_head_neutral); + + &.selected { + background-color: map-get($colors, #{$color}_line_head_chosen); + } + + &.unselected { + background-color: map-get($colors, #{$color}_line_not_chosen); + } + } + } +} + + +#conflicts { + + .white { + @include color-scheme('white') + } + + .dark { + @include color-scheme('dark') + } + + .monokai { + @include color-scheme('monokai') + } + + .solarized-light { + @include color-scheme('solarized_light') + } + + .solarized-dark { + @include color-scheme('solarized_dark') + } + + .diff-wrap-lines .line_content { + white-space: normal; + min-height: 19px; + } + + .line_content.header { + position: relative; + + button { + border-radius: 2px; + font-size: 10px; + position: absolute; + right: 10px; + padding: 0; + outline: none; + color: #fff; + width: 75px; // static width to make 2 buttons have same width + height: 19px; + } + } + + .btn-success .fa-spinner { + color: #fff; + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 0a661e529f0..6a0fae8a3f9 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -6,7 +6,7 @@ background: $background-color; color: $gl-gray; border: 1px solid $border-color; - @include border-radius(2px); + border-radius: 2px; form { margin-bottom: 0; @@ -69,6 +69,11 @@ &.ci-success { color: $gl-success; + + a.environment, + a.pipeline { + color: inherit; + } } &.ci-success_with_warnings { @@ -116,6 +121,10 @@ color: #5c5d5e; } + .js-deployment-link { + display: inline-block; + } + .mr-widget-body { h4 { font-weight: 600; @@ -126,7 +135,6 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } - } p:last-child { @@ -200,6 +208,18 @@ word-break: break-all; } +.commits-empty { + text-align: center; + + h4 { + padding-top: 20px; + padding-bottom: 10px; + } + svg { + width: 230px; + } +} + .mr-list { .merge-request { padding: 10px 15px; @@ -228,10 +248,6 @@ .merge-request-labels { display: inline-block; } - - .merge-request-no-comments { - opacity: 0.5; - } } .merge-request-angle { @@ -266,7 +282,7 @@ .builds { .table-holder { - overflow-x: scroll; + overflow-x: auto; } } @@ -350,6 +366,10 @@ .issuable-form-select-holder { display: inline-block; width: 250px; + + .dropdown-menu-toggle { + width: 100%; + } } .table-holder { @@ -371,3 +391,49 @@ } } } + +.mr-version-controls { + background: $background-color; + border-bottom: 1px solid $border-color; + color: $gl-text-color; + + .mr-version-menus-container { + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center; + padding: 16px; + } + + .content-block { + border-top: 1px solid $border-color; + padding: $gl-padding-top $gl-padding; + } + + .comments-disabled-notif { + .btn { + margin-left: 5px; + } + } + + .mr-version-dropdown, + .mr-version-compare-dropdown { + margin: 0 7px; + } + + .dropdown-title { + color: $gl-text-color; + } + + .fa-info-circle { + color: $orange-normal; + padding-right: 5px; + } +} + +.merge-request-details { + + .title { + margin-bottom: 20px; + } +} diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index b94f524b513..8c2ba3ed58c 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -2,13 +2,17 @@ max-width: 90%; } -li.milestone { - h4 { - font-weight: bold; - } +.milestones { + .milestone { + padding: 10px 16px; + + h4 { + font-weight: bold; + } - .progress { - height: 6px; + .progress { + height: 6px; + } } } @@ -29,6 +33,7 @@ li.milestone { // Issue title span a { color: $gl-text-color; + word-wrap: break-word; } } } @@ -64,3 +69,14 @@ li.milestone { border-bottom: 1px solid $border-color; padding: 20px 0; } + +@media (max-width: $screen-sm-min) { + .milestone-actions { + @include clearfix(); + padding-top: $gl-vert-padding; + + .btn:first-child { + margin-left: 0; + } + } +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3784010348a..bd875b9823f 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -159,6 +159,32 @@ } } +.discussion-with-resolve-btn { + display: table; + width: 100%; + border-collapse: separate; + table-layout: auto; + + .btn-group { + display: table-cell; + float: none; + width: 1%; + + &:first-child { + width: 100%; + padding-right: 5px; + } + + &:last-child { + padding-left: 5px; + } + } + + .btn { + width: 100%; + } +} + .discussion-notes-count { font-size: 16px; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index a2b5437e503..d399f84a2ff 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -281,19 +281,13 @@ ul.notes { font-size: 17px; } - &.js-note-delete { - i { - &:hover { - color: $gl-text-red; - } + &:hover { + .danger-highlight { + color: $gl-text-red; } - } - &.js-note-edit { - i { - &:hover { - color: $gl-link-color; - } + .link-highlight { + color: $gl-link-color; } } } @@ -340,7 +334,7 @@ ul.notes { .add-diff-note { margin-top: -4px; - @include border-radius(40px); + border-radius: 40px; background: #fff; padding: 4px; font-size: 16px; @@ -383,3 +377,80 @@ ul.notes { color: $gl-link-color; } } + +.line-resolve-all-container { + .btn-group { + margin-top: -1px; + margin-left: -4px; + } + + .discussion-next-btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + +.line-resolve-all { + display: inline-block; + padding: 5px 10px; + background-color: $background-color; + border: 1px solid $border-color; + border-radius: $border-radius-default; + + &.has-next-btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .line-resolve-btn { + vertical-align: middle; + margin-right: 5px; + } +} + +.line-resolve-text { + vertical-align: middle; +} + +.line-resolve-btn { + display: inline-block; + position: relative; + top: 2px; + padding: 0; + background-color: transparent; + border: none; + outline: 0; + + &.is-disabled { + cursor: default; + } + + &:not(.is-disabled):hover, + &:not(.is-disabled):focus, + &.is-active { + color: $gl-text-green; + + svg path { + fill: $gl-text-green; + } + } + + svg { + position: relative; + color: $notes-action-color; + + path { + fill: $notes-action-color; + } + } +} + +.discussion-next-btn { + svg { + margin: 0; + + path { + fill: $gray-darkest; + } + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 21919fe4d73..7843355f0ab 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -2,6 +2,7 @@ .stage { max-width: 90px; width: 90px; + text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -21,6 +22,11 @@ .table.builds { min-width: 1200px; + + .branch-commit { + width: 33%; + } + } } @@ -146,16 +152,40 @@ } .stage-cell { + font-size: 0; svg { height: 18px; width: 18px; + position: relative; + z-index: 2; vertical-align: middle; overflow: visible; } - .light { - width: 3px; + .stage-container { + display: inline-block; + position: relative; + margin-right: 6px; + + .tooltip { + white-space: nowrap; + } + + &:not(:last-child) { + &::after { + content: ''; + width: 8px; + position: absolute;; + right: -7px; + bottom: 8px; + border-bottom: 2px solid $border-color; + } + } + + a { + display: block; + } } } @@ -199,9 +229,12 @@ .fa { color: $table-text-gray; - margin-right: 6px; font-size: 14px; } + + svg, .fa { + margin-right: 0; + } } .btn-remove { @@ -215,6 +248,13 @@ border-color: $border-white-normal; } } + + .btn { + .icon-play { + height: 13px; + width: 12px; + } + } } } @@ -229,3 +269,377 @@ box-shadow: none; } } + +// Pipeline visualization + +.toggle-pipeline-btn { + background-color: $gray-dark; + + &.graph-collapsed { + background-color: $white-light; + } +} + +.pipeline-graph { + width: 100%; + overflow: auto; + white-space: nowrap; + transition: max-height 0.3s, padding 0.3s; + + &.graph-collapsed { + max-height: 0; + padding: 0 16px; + } +} + +.pipeline-visualization { + position: relative; + + ul { + padding: 0; + } +} + +.stage-column { + display: inline-block; + vertical-align: top; + + &:not(:last-child) { + margin-right: 44px; + } + + &.left-margin { + &:not(:first-child) { + margin-left: 44px; + + .left-connector { + &::before { + content: ''; + position: absolute; + top: 48%; + left: -48px; + border-top: 2px solid $border-color; + width: 48px; + height: 1px; + } + } + } + } + + &.no-margin { + margin: 0; + } + + li { + list-style: none; + } + + .stage-name { + margin: 0 0 15px 10px; + font-weight: bold; + width: 176px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .build { + border: 1px solid $border-color; + position: relative; + padding: 7px 10px 8px; + border-radius: 30px; + width: 186px; + margin-bottom: 10px; + + &:hover { + background-color: $gray-lighter; + .dropdown-menu-toggle { + background-color: transparent; + } + } + + &.playable { + + svg { + height: 13px; + width: 20px; + position: relative; + top: 1px; + + path { + fill: $layout-link-gray; + } + } + } + + .build-content { + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + width: 164px; + + .ci-status-icon { + svg { + height: 20px; + width: 20px; + } + } + + .ci-status-text { + width: 135px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + display: inline-block; + position: relative; + top: -1px; + } + + a { + color: $gl-text-color-light; + text-decoration: none; + } + + .dropdown-menu-toggle { + border: none; + width: auto; + padding: 0; + color: $gl-text-color-light; + flex-grow: 1; + + .ci-status-text { + max-width: 112px; + width: auto; + } + } + + .grouped-pipeline-dropdown { + padding: 8px 0; + width: 186px; + left: auto; + right: -197px; + top: -9px; + + ul { + max-height: 245px; + overflow: auto; + } + + a { + color: $gl-text-color; + padding: 7px 8px 8px; + + &:hover { + background-color: $blue-light-transparent; + border-radius: 3px; + + .ci-status-text { + text-decoration: none; + } + } + } + + svg { + width: 14px; + height: 14px; + } + + .ci-status-text { + width: 112px; + } + + .arrow { + &:before, + &:after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: 18px; + } + + &:before { + left: -5px; + margin-top: -6px; + border-width: 7px 5px 7px 0; + border-right-color: $border-color; + } + + &:after { + left: -4px; + margin-top: -9px; + border-width: 10px 7px 10px 0; + border-right-color: $white-light; + } + } + } + + .badge { + background-color: $gray-darker; + color: $gl-text-color-light; + font-weight: normal; + margin-left: $btn-xs-side-margin; + } + } + + svg { + vertical-align: middle; + margin-right: 5px; + } + + // Connect first build in each stage with right horizontal line + &:first-child { + &::after { + content: ''; + position: absolute; + top: 48%; + right: -48px; + border-top: 2px solid $border-color; + width: 48px; + height: 1px; + } + } + + // Connect each build (except for first) with curved lines + &:not(:first-child) { + &::after, &::before { + content: ''; + top: -49px; + position: absolute; + border-bottom: 2px solid $border-color; + width: 25px; + height: 69px; + } + + // Right connecting curves + &::after { + right: -25px; + border-right: 2px solid $border-color; + border-radius: 0 0 20px; + } + + // Left connecting curves + &::before { + left: -25px; + border-left: 2px solid $border-color; + border-radius: 0 0 0 20px; + } + } + + // Connect second build to first build with smaller curved line + &:nth-child(2) { + &::after, &::before { + height: 29px; + top: -9px; + } + .curve { + display: block; + } + } + } + + &:last-child { + .build { + // Remove right connecting horizontal line from first build in last stage + &:first-child { + &::after, &::before { + border: none; + } + } + // Remove right curved connectors from all builds in last stage + &:not(:first-child) { + &::after { + border: none; + } + } + // Remove opposite curve + .curve { + &::before { + display: none; + } + } + } + } + + &:first-child { + .build { + // Remove left curved connectors from all builds in first stage + &:not(:first-child) { + &::before { + border: none; + } + } + // Remove opposite curve + .curve { + &::after { + display: none; + } + } + } + } + + // Curve first child connecting lines in opposite direction + .curve { + display: none; + + &::before, + &::after { + content: ''; + width: 21px; + height: 25px; + position: absolute; + top: -32px; + border-top: 2px solid $border-color; + } + + &::after { + left: -44px; + border-right: 2px solid $border-color; + border-radius: 0 20px; + } + + &::before { + right: -44px; + border-left: 2px solid $border-color; + border-radius: 20px 0 0; + } + } +} + +.pipeline-actions { + border-bottom: none; +} + +.toggle-pipeline-btn { + + .fa { + color: $dropdown-header-color; + } +} + +.pipelines.tab-pane { + + .content-list.pipelines { + overflow: auto; + } + + .stage { + max-width: 100px; + width: 100px; + } + + .pipeline-actions { + min-width: initial; + } +} + +.ci-status-icon-created { + + svg { + fill: $gray-darkest; + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 46371ec6871..c7eac5cf4b9 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -93,8 +93,9 @@ .profile-user-bio { // Limits the width of the user bio for readability. - max-width: 750px; - margin: auto; + max-width: 600px; + margin: 10px auto; + padding: 0 16px; } .user-avatar-button { @@ -212,19 +213,46 @@ } .user-profile { + + .cover-controls a { + margin-left: 5px; + } + + .profile-header { + margin: 0 auto; + + .avatar-holder { + width: 90px; + margin: 0 auto 10px; + } + } + @media (max-width: $screen-xs-max) { + .cover-block { padding-top: 20px; } .cover-controls { position: static; + padding: 0 16px; margin-bottom: 20px; + display: -webkit-flex; + display: flex; .btn { - display: inline-block; - width: 46%; + -webkit-flex-grow: 1; + flex-grow: 1; + &:first-child { + margin-left: 0; + } } } } } + +table.u2f-registrations { + th:not(:last-child), td:not(:last-child) { + border-right: solid 1px transparent; + } +}
\ No newline at end of file diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index e5859fe7384..f8da0983b77 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -4,7 +4,7 @@ text-align: center; .preview { - @include border-radius(4px); + border-radius: 4px; height: 80px; margin-bottom: 10px; @@ -47,7 +47,7 @@ width: 160px; img { - @include border-radius(4px); + border-radius: 4px; max-width: 100%; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 4409477916f..530fb0c0d05 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -99,7 +99,7 @@ margin-left: auto; margin-right: auto; margin-bottom: 15px; - max-width: 480px; + max-width: 700px; > p { margin-bottom: 0; @@ -146,7 +146,8 @@ } .project-repo-btn-group, - .notification-dropdown { + .notification-dropdown, + .project-dropdown { margin-left: 10px; } @@ -311,6 +312,14 @@ a.deploy-project-label { color: $gl-success; } +.lfs-enabled { + color: $gl-success; +} + +.lfs-disabled { + color: $gl-warning; +} + .breadcrumb.repo-breadcrumb { padding: 0; background: transparent; @@ -326,6 +335,10 @@ a.deploy-project-label { a { color: $gl-dark-link-color; } + + .dropdown-menu { + width: 240px; + } } .last-push-widget { @@ -341,7 +354,7 @@ a.deploy-project-label { justify-content: flex-start; .fork-thumbnail { - @include border-radius($border-radius-base); + border-radius: $border-radius-base; background-color: $white-light; border: 1px solid $border-white-light; height: 202px; @@ -358,7 +371,7 @@ a.deploy-project-label { background-color: $gray-light; border: 1px solid $gray-dark; margin: 0 auto; - @include border-radius(50%); + border-radius: 50%; i { font-size: 100px; color: $gray-dark; @@ -377,7 +390,7 @@ a.deploy-project-label { } img { - @include border-radius(50%); + border-radius: 50%; max-width: 100px; } } @@ -483,7 +496,7 @@ pre.light-well { } .light-well { - @include border-radius (2px); + border-radius: 2px; color: #5b6169; font-size: 13px; @@ -512,18 +525,12 @@ pre.light-well { .project-row { border-color: $table-border-color; - &.no-description { - .project { - line-height: 40px; - } - } - .project-full-name { @include str-truncated; } .controls { - line-height: 40px; + line-height: $list-text-height; a:hover { text-decoration: none; @@ -606,18 +613,25 @@ pre.light-well { } } -.project-show-readme .readme-holder { - padding: $gl-padding 0; - border-top: 0; - - .edit-project-readme { - z-index: 2; - position: relative; +.project-show-readme { + .row-content-block { + background-color: inherit; + border: none; } - .wiki h1 { - border-bottom: none; - padding: 0; + .readme-holder { + padding: $gl-padding 0; + border-top: 0; + + .edit-project-readme { + z-index: 2; + position: relative; + } + + .wiki h1 { + border-bottom: none; + padding: 0; + } } } @@ -662,13 +676,9 @@ pre.light-well { } .new_protected_branch { - .dropdown { - display: inline; - margin-left: 15px; - } - label { - min-width: 120px; + margin-top: 6px; + font-weight: normal; } } @@ -684,6 +694,21 @@ pre.light-well { font-weight: 600; } } + + .settings-message { + margin: 0; + border-radius: 0 0 1px 1px; + padding: 20px 0; + border: none; + } + + .table-bordered { + border-radius: 1px; + + th:not(:last-child), td:not(:last-child) { + border-right: solid 1px transparent; + } + } } .custom-notifications-form { @@ -703,9 +728,15 @@ pre.light-well { } } -.project-refs-form { - .dropdown-menu { - width: 300px; +.project-refs-form .dropdown-menu, .dropdown-menu-projects { + width: 300px; + + @media (min-width: $screen-sm-min) { + width: 500px; + } + + a { + white-space: normal; } } @@ -713,4 +744,96 @@ pre.light-well { .dropdown-menu { width: 300px; } + + &.from .compare-dropdown-toggle { + width: 237px; + } + + &.to .compare-dropdown-toggle { + width: 254px; + } + + .dropdown-toggle-text { + display: block; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + } +} + +.compare-ellipsis { + display: inline; +} + +@media (max-width: $screen-xs-max) { + .compare-form-group { + .input-group { + width: 100%; + + & > .compare-dropdown-toggle { + width: 100%; + } + } + + .dropdown-menu { + width: 100%; + } + } + + .compare-switch-container { + text-align: center; + padding: 0 0 $gl-padding; + + .commits-compare-switch { + float: none; + } + } + + .compare-ellipsis { + display: block; + text-align: center; + padding: 0 0 $gl-padding; + } + + .commits-compare-btn { + width: 100%; + } +} + +.clearable-input { + position: relative; + + .clear-icon { + @extend .fa-times; + display: none; + position: absolute; + right: 7px; + top: 7px; + color: $location-icon-color; + + &:before { + font-family: FontAwesome; + font-weight: normal; + font-style: normal; + } + } + + &.has-value { + .clear-icon { + cursor: pointer; + display: block; + } + } +} + +.project-path { + .form-control { + min-width: 100px; + } + .select2-choice { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index c9d436d72ba..e77f9816d8a 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -80,7 +80,7 @@ .search-icon { @extend .fa-search; - @include transition(color .15s); + transition: color 0.15s; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -103,7 +103,7 @@ // Custom dropdown positioning .dropdown-menu { - top: 30px; + top: 37px; left: -5px; padding: 0; @@ -125,7 +125,7 @@ } .location-badge { - @include transition(all .15s); + transition: all 0.15s; background-color: $location-badge-active-bg; color: $white-light; } diff --git a/app/assets/stylesheets/pages/snippets.scss b/app/assets/stylesheets/pages/snippets.scss index 2aa939b7dc3..857eb76131a 100644 --- a/app/assets/stylesheets/pages/snippets.scss +++ b/app/assets/stylesheets/pages/snippets.scss @@ -2,20 +2,6 @@ padding: 2px; } -.snippet-holder { - margin-bottom: -$gl-padding; - - .file-holder { - border-top: 0; - } - - .file-actions { - .btn-clipboard { - @extend .btn; - } - } -} - .markdown-snippet-copy { position: fixed; top: -10px; @@ -24,29 +10,25 @@ max-width: 0; } -.file-holder.snippet-file-content { - padding-bottom: $gl-padding; - border-bottom: 1px solid $border-color; - - .file-title { - padding-top: $gl-padding; - padding-bottom: $gl-padding; - } +.snippet-file-content { + border-radius: 3px; + margin-bottom: $gl-padding; - .file-actions { - top: 12px; + .btn-clipboard { + @extend .btn; } +} - .file-content { - border-left: 1px solid $border-color; - border-right: 1px solid $border-color; - border-bottom: 1px solid $border-color; - } +.project-snippets .awards { + border-bottom: 1px solid $table-border-color; + padding-bottom: $gl-padding; } .snippet-title { font-size: 24px; - font-weight: normal; + font-weight: 600; + padding: $gl-padding; + padding-left: 0; } .snippet-actions { @@ -54,3 +36,7 @@ float: right; } } + +.snippet-scope-menu .btn-new { + margin-top: 15px; +} diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 587f2d9f3c1..c05f3d5ff32 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -4,7 +4,7 @@ margin-right: 10px; border: 1px solid #eee; white-space: nowrap; - @include border-radius(4px); + border-radius: 4px; &:hover { text-decoration: none; @@ -43,6 +43,15 @@ border-color: $blue-normal; } + &.ci-created { + color: $table-text-gray; + border-color: $table-text-gray; + + svg { + fill: $table-text-gray; + } + } + svg { height: 13px; width: 13px; diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index cf16d070cfe..ea76fe18876 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -20,10 +20,44 @@ } } -.todo { +.todos-list > .todo { + // workaround because we cannot use border-colapse + border-top: 1px solid transparent; + display: -webkit-flex; + display: flex; + -webkit-flex-direction: row; + flex-direction: row; + &:hover { + background-color: $row-hover; + border-color: $row-hover-border; cursor: pointer; } + + // overwrite border style of .content-list + &:last-child { + border-bottom: 1px solid transparent; + + &:hover { + border-color: $row-hover-border; + } + } + + .todo-actions { + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + -webkit-flex-direction: column; + flex-direction: column; + margin-left: 10px; + min-width: 55px; + } + + .todo-item { + -webkit-flex: auto; + flex: auto; + } } .todo-item { @@ -43,8 +77,6 @@ } .todo-body { - margin-right: 174px; - .todo-note { word-wrap: break-word; @@ -68,7 +100,7 @@ pre { border: none; - background: #f9f9f9; + background: $gray-light; border-radius: 0; color: #777; margin: 0 20px; @@ -89,7 +121,21 @@ } } +@media (max-width: $screen-sm-max) { + .todos-filters { + .dropdown-menu-toggle { + width: 135px; + } + } +} + @media (max-width: $screen-xs-max) { + .todo { + .avatar { + display: none; + } + } + .todo-item { .todo-title { white-space: normal; @@ -98,14 +144,20 @@ margin-bottom: 10px; } - .avatar { - display: none; - } - .todo-body { margin: 0; border-left: 2px solid #ddd; padding-left: 10px; } } + + .todos-filters { + .row-content-block { + padding-bottom: 50px; + } + + .dropdown-menu-toggle { + width: 100%; + } + } } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 9da40fe2b09..41ad10f07bd 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -11,6 +11,10 @@ } } + .add-to-tree { + vertical-align: top; + } + .tree-table { margin-bottom: 0; @@ -22,6 +26,20 @@ line-height: 21px; } + .last-commit { + @include str-truncated(506px); + + @media (min-width: $screen-sm-max) and (max-width: $screen-md-max) { + @include str-truncated(450px); + } + + } + + .commit-history-link-spacer { + margin: 0 10px; + color: $table-border-color; + } + &:hover { td { background-color: $row-hover; @@ -42,6 +60,15 @@ } .tree-item { + .link-container { + padding: 0; + + a { + padding: 10px $gl-padding; + display: block; + } + } + .tree-item-file-name { max-width: 320px; vertical-align: middle; @@ -77,11 +104,17 @@ } } - .tree_commit { - color: $gl-gray; + .tree-time-ago { + min-width: 135px; + color: $gl-gray-light; + } + + .tree-commit { + max-width: 320px; + color: $gl-gray-light; .tree-commit-link { - color: $gl-gray; + color: $gl-gray-light; &:hover { text-decoration: underline; diff --git a/app/assets/stylesheets/pages/xterm.scss b/app/assets/stylesheets/pages/xterm.scss index 8d855ce99b0..c9846103762 100644 --- a/app/assets/stylesheets/pages/xterm.scss +++ b/app/assets/stylesheets/pages/xterm.scss @@ -20,6 +20,9 @@ $l-cyan: #8abeb7; $l-white: $ci-text-color; + .term-bold { + font-weight: bold; + } .term-italic { font-style: italic; } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 9e1dc15de84..6ef7cf0bae6 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -109,6 +109,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :sentry_dsn, :akismet_enabled, :akismet_api_key, + :koding_enabled, + :koding_url, :email_author_in_body, :repository_checks_enabled, :metrics_packet_size, diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index 82055006ac0..762e36ee2e9 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -37,7 +37,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController end def preview - @message = broadcast_message_params[:message] + @broadcast_message = BroadcastMessage.new(broadcast_message_params) end protected diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index f3a88a8e6c8..aa7570cd896 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -10,7 +10,7 @@ class Admin::GroupsController < Admin::ApplicationController def show @members = @group.members.order("access_level DESC").page(params[:members_page]) - @requesters = @group.requesters + @requesters = AccessRequestsFinder.new(@group).execute(current_user) @projects = @group.projects.page(params[:projects_page]) end @@ -42,15 +42,15 @@ class Admin::GroupsController < Admin::ApplicationController end def members_update - @group.add_users(params[:user_ids].split(','), params[:access_level], current_user) + @group.add_users(params[:user_ids].split(','), params[:access_level], current_user: current_user) redirect_to [:admin, @group], notice: 'Users were successfully added.' end def destroy - DestroyGroupService.new(@group, current_user).execute + DestroyGroupService.new(@group, current_user).async_execute - redirect_to admin_groups_path, notice: 'Group was successfully deleted.' + redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion." end private @@ -60,6 +60,14 @@ class Admin::GroupsController < Admin::ApplicationController end def group_params - params.require(:group).permit(:name, :description, :path, :avatar, :visibility_level, :request_access_enabled) + params.require(:group).permit( + :avatar, + :description, + :lfs_enabled, + :name, + :path, + :request_access_enabled, + :visibility_level + ) end end diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 8be35f00a77..9433da02f64 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -7,7 +7,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController warden.set_user(impersonator, scope: :user) - Gitlab::AppLogger.info("User #{original_user.username} has stopped impersonating #{impersonator.username}") + Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{original_user.username}") session[:impersonator_id] = nil diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 0d2f4f6eb38..1d963bdf7d5 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -22,7 +22,7 @@ class Admin::ProjectsController < Admin::ApplicationController end @project_members = @project.members.page(params[:project_members_page]) - @requesters = @project.requesters + @requesters = AccessRequestsFinder.new(@project).execute(current_user) end def transfer diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 3a2f0185315..2abfa22712d 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -14,4 +14,14 @@ class Admin::SpamLogsController < Admin::ApplicationController head :ok end end + + def mark_as_ham + spam_log = SpamLog.find(params[:id]) + + if HamService.new(spam_log).mark_as_ham! + redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' + else + redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.' + end + end end diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb index e4c73008826..ca04a17caa1 100644 --- a/app/controllers/admin/system_info_controller.rb +++ b/app/controllers/admin/system_info_controller.rb @@ -29,7 +29,8 @@ class Admin::SystemInfoController < Admin::ApplicationController ] def show - system_info = Vmstat.snapshot + @cpus = Vmstat.cpu rescue nil + @memory = Vmstat.memory rescue nil mounts = Sys::Filesystem.mounts @disks = [] @@ -50,10 +51,5 @@ class Admin::SystemInfoController < Admin::ApplicationController rescue Sys::Filesystem::Error end end - - @cpus = system_info.cpus.length - - @mem_used = system_info.memory.active_bytes - @mem_total = system_info.memory.total_bytes end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 634d36a4467..b3455e04c29 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base include Gitlab::GonHelper include GitlabRoutingHelper include PageLayoutHelper + include SentryHelper include WorkhorseHelper before_action :authenticate_user_from_private_token! @@ -23,8 +24,8 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception - helper_method :abilities, :can?, :current_application_settings - helper_method :import_sources_enabled?, :github_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :gitorious_import_enabled?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled? + helper_method :can?, :current_application_settings + helper_method :import_sources_enabled?, :github_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled? rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) @@ -46,28 +47,6 @@ class ApplicationController < ActionController::Base protected - def sentry_context - if Rails.env.production? && current_application_settings.sentry_enabled - if current_user - Raven.user_context( - id: current_user.id, - email: current_user.email, - username: current_user.username, - ) - end - - Raven.tags_context(program: sentry_program_context) - end - end - - def sentry_program_context - if Sidekiq.server? - 'sidekiq' - else - 'rails' - end - end - # This filter handles both private tokens and personal access tokens def authenticate_user_from_private_token! token_string = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence @@ -118,12 +97,8 @@ class ApplicationController < ActionController::Base current_application_settings.after_sign_out_path.presence || new_user_session_path end - def abilities - Ability.abilities - end - def can?(object, action, subject) - abilities.allowed?(object, action, subject) + Ability.allowed?(object, action, subject) end def access_denied! @@ -198,7 +173,8 @@ class ApplicationController < ActionController::Base end def event_filter - filters = cookies['event_filter'].split(',') if cookies['event_filter'].present? + # Split using comma to maintain backward compatibility Ex/ "filter1,filter2" + filters = cookies['event_filter'].split(',')[0] if cookies['event_filter'].present? @event_filter ||= EventFilter.new(filters) end @@ -271,10 +247,6 @@ class ApplicationController < ActionController::Base Gitlab::OAuth::Provider.enabled?(:bitbucket) && Gitlab::BitbucketImport.public_key.present? end - def gitorious_import_enabled? - current_application_settings.import_sources.include?('gitorious') - end - def google_code_import_enabled? current_application_settings.import_sources.include?('google_code') end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index d828d163c28..b48668eea87 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,5 +1,6 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users] + before_action :load_project, only: [:users] before_action :find_users, only: [:users] def users @@ -34,19 +35,13 @@ class AutocompleteController < ApplicationController def projects project = Project.find_by_id(params[:project_id]) - - projects = current_user.authorized_projects - projects = projects.search(params[:search]) if params[:search].present? - projects = projects.select do |project| - current_user.can?(:admin_issue, project) - end + projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id]) no_project = { id: 0, name_with_namespace: 'No project', } - projects.unshift(no_project) - projects.delete(project) + projects.unshift(no_project) unless params[:offset_id].present? render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) end @@ -55,11 +50,8 @@ class AutocompleteController < ApplicationController def find_users @users = - if params[:project_id].present? - project = Project.find(params[:project_id]) - return render_404 unless can?(current_user, :read_project, project) - - project.team.users + if @project + @project.team.users elsif params[:group_id].present? group = Group.find(params[:group_id]) return render_404 unless can?(current_user, :read_group, group) @@ -71,4 +63,18 @@ class AutocompleteController < ApplicationController User.none end end + + def load_project + @project ||= begin + if params[:project_id].present? + project = Project.find(params[:project_id]) + return render_404 unless can?(current_user, :read_project, project) + project + end + end + end + + def projects_finder + MoveToProjectFinder.new(current_user) + end end diff --git a/app/controllers/ci/application_controller.rb b/app/controllers/ci/application_controller.rb deleted file mode 100644 index 5bb7d499cdc..00000000000 --- a/app/controllers/ci/application_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Ci - class ApplicationController < ::ApplicationController - def self.railtie_helpers_paths - "app/helpers/ci" - end - end -end diff --git a/app/controllers/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb index a7af3cb8345..3eb485de9db 100644 --- a/app/controllers/ci/lints_controller.rb +++ b/app/controllers/ci/lints_controller.rb @@ -1,5 +1,5 @@ module Ci - class LintsController < ApplicationController + class LintsController < ::ApplicationController before_action :authenticate_user! def show @@ -7,19 +7,15 @@ module Ci def create @content = params[:content] + @error = Ci::GitlabCiYamlProcessor.validation_message(@content) + @status = @error.blank? - if @content.blank? - @status = false - @error = "Please provide content of .gitlab-ci.yml" - else + if @error.blank? @config_processor = Ci::GitlabCiYamlProcessor.new(@content) @stages = @config_processor.stages @builds = @config_processor.builds - @status = true + @jobs = @config_processor.jobs end - rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e - @error = e.message - @status = false rescue @error = 'Undefined error' @status = false diff --git a/app/controllers/ci/projects_controller.rb b/app/controllers/ci/projects_controller.rb index aa894fde36b..ff297d6ff13 100644 --- a/app/controllers/ci/projects_controller.rb +++ b/app/controllers/ci/projects_controller.rb @@ -1,5 +1,5 @@ module Ci - class ProjectsController < Ci::ApplicationController + class ProjectsController < ::ApplicationController before_action :project before_action :no_cache, only: [:badge] before_action :authorize_read_project!, except: [:badge, :index] diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index ba07cea569c..4c497711fc0 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -23,15 +23,24 @@ module AuthenticatesWithTwoFactor # # Returns nil def prompt_for_two_factor(user) + return locked_user_redirect(user) if user.access_locked? + session[:otp_user_id] = user.id setup_u2f_authentication(user) render 'devise/sessions/two_factor' end + def locked_user_redirect(user) + flash.now[:alert] = 'Invalid Login or password' + render 'devise/sessions/new' + end + def authenticate_with_two_factor user = self.resource = find_user - if user_params[:otp_attempt].present? && session[:otp_user_id] + if user.access_locked? + locked_user_redirect(user) + elsif user_params[:otp_attempt].present? && session[:otp_user_id] authenticate_with_two_factor_via_otp(user) elsif user_params[:device_response].present? && session[:otp_user_id] authenticate_with_two_factor_via_u2f(user) @@ -50,8 +59,9 @@ module AuthenticatesWithTwoFactor remember_me(user) if user_params[:remember_me] == '1' sign_in(user) else + user.increment_failed_attempts! flash.now[:alert] = 'Invalid two-factor code.' - render :two_factor + prompt_for_two_factor(user) end end @@ -62,8 +72,10 @@ module AuthenticatesWithTwoFactor session.delete(:otp_user_id) session.delete(:challenges) + remember_me(user) if user_params[:remember_me] == '1' sign_in(user) else + user.increment_failed_attempts! flash.now[:alert] = 'Authentication via U2F device failed.' prompt_for_two_factor(user) end diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index f2b8f297bc2..dacb5679dd3 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -7,8 +7,7 @@ module CreatesCommit commit_params = @commit_params.merge( source_project: @project, source_branch: @ref, - target_branch: @target_branch, - previous_path: @previous_path + target_branch: @target_branch ) result = service.new(@tree_edit_project, current_user, commit_params).execute diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index f40b62446e5..bb32bc502e6 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -3,21 +3,54 @@ module IssuableActions included do before_action :authorize_destroy_issuable!, only: :destroy + before_action :authorize_admin_issuable!, only: :bulk_update end def destroy issuable.destroy + destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym + TodoService.new.public_send(destroy_method, issuable, current_user) name = issuable.class.name.titleize.downcase flash[:notice] = "The #{name} was successfully deleted." redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) end + def bulk_update + result = Issuable::BulkUpdateService.new(project, current_user, bulk_update_params).execute(resource_name) + quantity = result[:count] + + render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } + end + private def authorize_destroy_issuable! - unless current_user.can?(:"destroy_#{issuable.to_ability_name}", issuable) + unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable) return access_denied! end end + + def authorize_admin_issuable! + unless can?(current_user, :"admin_#{resource_name}", @project) + return access_denied! + end + end + + def bulk_update_params + params.require(:update).permit( + :issuable_ids, + :assignee_id, + :milestone_id, + :state_event, + :subscription_event, + label_ids: [], + add_label_ids: [], + remove_label_ids: [] + ) + end + + def resource_name + @resource_name ||= controller_name.singularize + end end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c802922e0af..b5e79099e39 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -66,6 +66,11 @@ module IssuableCollections key = 'issuable_sort' cookies[key] = params[:sort] if params[:sort].present? + + # id_desc and id_asc are old values for these two. + cookies[key] = sort_value_recently_created if cookies[key] == 'id_desc' + cookies[key] = sort_value_oldest_created if cookies[key] == 'id_asc' + params[:sort] = cookies[key] end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 52682ef9dc9..c13333641d3 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -1,6 +1,5 @@ module MembershipActions extend ActiveSupport::Concern - include MembersHelper def request_access membershipable.request_access(current_user) @@ -10,28 +9,23 @@ module MembershipActions end def approve_access_request - @member = membershipable.requesters.find(params[:id]) - - return render_403 unless can?(current_user, action_member_permission(:update, @member), @member) - - @member.accept_request + Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute redirect_to polymorphic_url([membershipable, :members]) end def leave - @member = membershipable.members.find_by(user_id: current_user) || - membershipable.requesters.find_by(user_id: current_user) - Members::DestroyService.new(@member, current_user).execute + member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id). + execute(:all) - source_type = @member.real_source_type.humanize(capitalize: false) + source_type = membershipable.class.to_s.humanize(capitalize: false) notice = - if @member.request? + if member.request? "Your access request to the #{source_type} has been withdrawn." else - "You left the \"#{@member.source.human_name}\" #{source_type}." + "You left the \"#{membershipable.human_name}\" #{source_type}." end - redirect_path = @member.request? ? @member.source : [:dashboard, @member.real_source_type.tableize] + redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize] redirect_to redirect_path, notice: notice end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 471d15af913..4cb3be41064 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -7,11 +7,16 @@ module ServiceParams :build_key, :server, :teamcity_url, :drone_url, :build_type, :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, :colorize_messages, :channels, - :push_events, :issues_events, :merge_requests_events, :tag_push_events, - :note_events, :build_events, :wiki_page_events, - :notify_only_broken_builds, :add_pusher, - :send_from_committer_email, :disable_diffs, :external_wiki_url, - :notify, :color, + # 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] @@ -19,9 +24,7 @@ module ServiceParams FILTER_BLANK_PARAMS = [:password] def service_params - dynamic_params = [] - dynamic_params.concat(@service.event_channel_names) - + dynamic_params = @service.event_channel_names + @service.event_names service_params = params.permit(:id, service: ALLOWED_PARAMS + dynamic_params) if service_params[:service].is_a?(Hash) diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb new file mode 100644 index 00000000000..99acd98ae13 --- /dev/null +++ b/app/controllers/concerns/spammable_actions.rb @@ -0,0 +1,25 @@ +module SpammableActions + extend ActiveSupport::Concern + + included do + before_action :authorize_submit_spammable!, only: :mark_as_spam + end + + def mark_as_spam + if SpamService.new(spammable).mark_as_spam! + redirect_to spammable, notice: "#{spammable.class} was submitted to Akismet successfully." + else + redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.' + end + end + + private + + def spammable + raise NotImplementedError, "#{self.class} does not implement #{__method__}" + end + + def authorize_submit_spammable! + access_denied! unless current_user.admin? + end +end diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb index 036777c80c1..3717c49f272 100644 --- a/app/controllers/concerns/toggle_award_emoji.rb +++ b/app/controllers/concerns/toggle_award_emoji.rb @@ -8,10 +8,16 @@ module ToggleAwardEmoji def toggle_award_emoji name = params.require(:name) - awardable.toggle_award_emoji(name, current_user) - TodoService.new.new_award_emoji(to_todoable(awardable), current_user) + if awardable.user_can_award?(current_user, name) + awardable.toggle_award_emoji(name, current_user) - render json: { ok: true } + todoable = to_todoable(awardable) + TodoService.new.new_award_emoji(todoable, current_user) if todoable + + render json: { ok: true } + else + render json: { ok: false } + end end private @@ -20,8 +26,10 @@ module ToggleAwardEmoji case awardable when Note awardable.noteable - else + when MergeRequest, Issue awardable + when Snippet + nil end end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 19a76a5b5d8..d425d0f9014 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -2,11 +2,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController before_action :find_todos, only: [:index, :destroy_all] def index + @sort = params[:sort] @todos = @todos.page(params[:page]) end def destroy - TodoService.new.mark_todos_as_done([todo], current_user) + TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user) respond_to do |format| format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' } @@ -27,18 +28,14 @@ class Dashboard::TodosController < Dashboard::ApplicationController private - def todo - @todo ||= find_todos.find(params[:id]) - end - def find_todos @todos ||= TodosFinder.new(current_user, params).execute end def todos_counts { - count: TodosFinder.new(current_user, state: :pending).execute.count, - done_count: TodosFinder.new(current_user, state: :done).execute.count + count: current_user.todos_pending_count, + done_count: current_user.todos_done_count } end end diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 88a0c18180b..a62c6211372 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -21,8 +21,7 @@ class Explore::ProjectsController < Explore::ApplicationController end def trending - @projects = TrendingProjectsFinder.new.execute(current_user) - @projects = filter_projects(@projects) + @projects = filter_projects(Project.trending) @projects = @projects.page(params[:page]) respond_to do |format| diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 9fc41a12536..18cd800c619 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -15,13 +15,18 @@ class Groups::GroupMembersController < Groups::ApplicationController end @members = @members.order('access_level DESC').page(params[:page]).per(50) - @requesters = @group.requesters if can?(current_user, :admin_group, @group) + @requesters = AccessRequestsFinder.new(@group).execute(current_user) @group_member = @group.group_members.new end def create - @group.add_users(params[:user_ids].split(','), params[:access_level], current_user) + @group.add_users( + params[:user_ids].split(','), + params[:access_level], + current_user: current_user, + expires_at: params[:expires_at] + ) redirect_to group_group_members_path(@group), notice: 'Users were successfully added.' end @@ -35,10 +40,7 @@ class Groups::GroupMembersController < Groups::ApplicationController end def destroy - @group_member = @group.members.find_by(id: params[:id]) || - @group.requesters.find_by(id: params[:id]) - - Members::DestroyService.new(@group_member, current_user).execute + Members::DestroyService.new(@group, current_user, id: params[:id]).execute(:all) respond_to do |format| format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } @@ -63,7 +65,7 @@ class Groups::GroupMembersController < Groups::ApplicationController protected def member_params - params.require(:group_member).permit(:access_level, :user_id) + params.require(:group_member).permit(:access_level, :user_id, :expires_at) end # MembershipActions concern diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 6780a6d4d87..b83c3a872cf 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -87,9 +87,9 @@ class GroupsController < Groups::ApplicationController end def destroy - DestroyGroupService.new(@group, current_user).execute + DestroyGroupService.new(@group, current_user).async_execute - redirect_to root_path, alert: "Group '#{@group.name}' was successfully deleted." + redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion." end protected @@ -121,7 +121,17 @@ class GroupsController < Groups::ApplicationController end def group_params - params.require(:group).permit(:name, :description, :path, :avatar, :public, :visibility_level, :share_with_group_lock, :request_access_enabled) + params.require(:group).permit( + :avatar, + :description, + :lfs_enabled, + :name, + :path, + :public, + :request_access_enabled, + :share_with_group_lock, + :visibility_level + ) end def load_events diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 7e8597a5eb3..256c41e6145 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -1,18 +1,17 @@ class Import::BaseController < ApplicationController private - def get_or_create_namespace + def find_or_create_namespace(name, owner) + return current_user.namespace if name == owner + return current_user.namespace unless current_user.can_create_group? + begin - namespace = Group.create!(name: @target_namespace, path: @target_namespace, owner: current_user) + name = params[:target_namespace].presence || name + namespace = Group.create!(name: name, path: name, owner: current_user) namespace.add_owner(current_user) + namespace rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid - namespace = Namespace.find_by_path_or_name(@target_namespace) - unless current_user.can?(:create_projects, namespace) - @already_been_taken = true - return false - end + Namespace.find_by_path_or_name(name) end - - namespace end end diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 944c73d139a..6ea54744da8 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -35,23 +35,20 @@ class Import::BitbucketController < Import::BaseController end def create - @repo_id = params[:repo_id] || "" - repo = client.project(@repo_id.gsub("___", "/")) - @project_name = repo["slug"] - - repo_owner = repo["owner"] - repo_owner = current_user.username if repo_owner == client.user["user"]["username"] - @target_namespace = params[:new_namespace].presence || repo_owner - - namespace = get_or_create_namespace || (render and return) + @repo_id = params[:repo_id].to_s + repo = client.project(@repo_id.gsub('___', '/')) + @project_name = repo['slug'] + @target_namespace = find_or_create_namespace(repo['owner'], client.user['user']['username']) unless Gitlab::BitbucketImport::KeyAdder.new(repo, current_user, access_params).execute - @access_denied = true - render - return + render 'deploy_key' and return end - @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, namespace, current_user, access_params).execute + if current_user.can?(:create_projects, @target_namespace) + @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute + else + render 'unauthorized' + end end private diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 9c1b0eb20f4..ee7d498c59c 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -40,15 +40,15 @@ class Import::GithubController < Import::BaseController def create @repo_id = params[:repo_id].to_i repo = client.repo(@repo_id) - @project_name = repo.name - - repo_owner = repo.owner.login - repo_owner = current_user.username if repo_owner == client.user.login - @target_namespace = params[:new_namespace].presence || repo_owner - - namespace = get_or_create_namespace || (render and return) - - @project = Gitlab::GithubImport::ProjectCreator.new(repo, namespace, current_user, access_params).execute + @project_name = params[:new_name].presence || repo.name + namespace_path = params[:target_namespace].presence || current_user.namespace_path + @target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) + + if current_user.can?(:create_projects, @target_namespace) + @project = Gitlab::GithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params).execute + else + render 'unauthorized' + end end private diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index 08130ee8176..73837ffbe67 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -26,15 +26,14 @@ class Import::GitlabController < Import::BaseController def create @repo_id = params[:repo_id].to_i repo = client.project(@repo_id) - @project_name = repo["name"] + @project_name = repo['name'] + @target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username']) - repo_owner = repo["namespace"]["path"] - repo_owner = current_user.username if repo_owner == client.user["username"] - @target_namespace = params[:new_namespace].presence || repo_owner - - namespace = get_or_create_namespace || (render and return) - - @project = Gitlab::GitlabImport::ProjectCreator.new(repo, namespace, current_user, access_params).execute + if current_user.can?(:create_projects, @target_namespace) + @project = Gitlab::GitlabImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute + else + render 'unauthorized' + end end private diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 30df1fb2fec..3ec173abcdb 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -12,13 +12,14 @@ class Import::GitlabProjectsController < Import::BaseController return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." }) end - imported_file = project_params[:file].path + "-import" + import_upload_path = Gitlab::ImportExport.import_upload_path(filename: project_params[:file].original_filename) - FileUtils.copy_entry(project_params[:file].path, imported_file) + FileUtils.mkdir_p(File.dirname(import_upload_path)) + FileUtils.copy_entry(project_params[:file].path, import_upload_path) @project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id], current_user, - File.expand_path(imported_file), + import_upload_path, project_params[:path]).execute if @project.saved? diff --git a/app/controllers/import/gitorious_controller.rb b/app/controllers/import/gitorious_controller.rb deleted file mode 100644 index a4c4ad23027..00000000000 --- a/app/controllers/import/gitorious_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -class Import::GitoriousController < Import::BaseController - before_action :verify_gitorious_import_enabled - - def new - redirect_to client.authorize_url(callback_import_gitorious_url) - end - - def callback - session[:gitorious_repos] = params[:repos] - redirect_to status_import_gitorious_path - end - - def status - @repos = client.repos - - @already_added_projects = current_user.created_projects.where(import_type: "gitorious") - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include? repo.full_name } - end - - def jobs - jobs = current_user.created_projects.where(import_type: "gitorious").to_json(only: [:id, :import_status]) - render json: jobs - end - - def create - @repo_id = params[:repo_id] - repo = client.repo(@repo_id) - @target_namespace = params[:new_namespace].presence || repo.namespace - @project_name = repo.name - - namespace = get_or_create_namespace || (render and return) - - @project = Gitlab::GitoriousImport::ProjectCreator.new(repo, namespace, current_user).execute - end - - private - - def client - @client ||= Gitlab::GitoriousImport::Client.new(session[:gitorious_repos]) - end - - def verify_gitorious_import_enabled - render_404 unless gitorious_import_enabled? - end -end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 014b9b43ff2..7e4da73bc11 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -11,7 +11,8 @@ class JwtController < ApplicationController service = SERVICES[params[:service]] return head :not_found unless service - result = service.new(@project, @user, auth_params).execute + result = service.new(@authentication_result.project, @authentication_result.actor, auth_params). + execute(authentication_abilities: @authentication_result.authentication_abilities || []) render json: result, status: result[:http_status] end @@ -19,31 +20,37 @@ class JwtController < ApplicationController private def authenticate_project_or_user - authenticate_with_http_basic do |login, password| - # if it's possible we first try to authenticate project with login and password - @project = authenticate_project(login, password) - return if @project + @authentication_result = Gitlab::Auth::Result.new - @user = authenticate_user(login, password) - return if @user + authenticate_with_http_basic do |login, password| + @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) - render_403 + render_unauthorized unless @authentication_result.success? && + (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User)) end + rescue Gitlab::Auth::MissingPersonalTokenError + render_missing_personal_token end - def auth_params - params.permit(:service, :scope, :account, :client_id) + def render_missing_personal_token + render json: { + errors: [ + { code: 'UNAUTHORIZED', + message: "HTTP Basic: Access denied\n" \ + "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \ + "You can generate one at #{profile_personal_access_tokens_url}" } + ] }, status: 401 end - def authenticate_project(login, password) - if login == 'gitlab-ci-token' - Project.find_by(builds_enabled: true, runners_token: password) - end + def render_unauthorized + render json: { + errors: [ + { code: 'UNAUTHORIZED', + message: 'HTTP Basic: Access denied' } + ] }, status: 401 end - def authenticate_user(login, password) - user = Gitlab::Auth.find_with_user_password(login, password) - Gitlab::Auth.rate_limit!(request.ip, success: user.present?, login: login) - user + def auth_params + params.permit(:service, :scope, :account, :client_id) end end diff --git a/app/controllers/koding_controller.rb b/app/controllers/koding_controller.rb new file mode 100644 index 00000000000..f3759b4c0ea --- /dev/null +++ b/app/controllers/koding_controller.rb @@ -0,0 +1,15 @@ +class KodingController < ApplicationController + before_action :check_integration!, :authenticate_user!, :reject_blocked! + layout 'koding' + + def index + path = File.join(Rails.root, 'doc/user/project/koding.md') + @markdown = File.read(path) + end + + private + + def check_integration! + render_404 unless current_application_settings.koding_enabled? + end +end diff --git a/app/controllers/namespaces_controller.rb b/app/controllers/namespaces_controller.rb deleted file mode 100644 index 5a94dcb0dbd..00000000000 --- a/app/controllers/namespaces_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -class NamespacesController < ApplicationController - skip_before_action :authenticate_user! - - def show - namespace = Namespace.find_by(path: params[:id]) - - if namespace - if namespace.is_a?(Group) - group = namespace - else - user = namespace.owner - end - end - - if user - redirect_to user_path(user) - elsif group && can?(current_user, :read_group, namespace) - redirect_to group_path(group) - elsif current_user.nil? - authenticate_user! - else - render_404 - end - end -end diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c780e0983f9..6217ec5ecef 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -50,6 +50,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController flash[:notice] = "Password was successfully updated. Please login with it" redirect_to new_user_session_path else + @user.reload render 'edit' end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e37e9e136db..9eb75bb3891 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController # A U2F (universal 2nd factor) device's information is stored after successful # registration, which is then used while 2FA authentication is taking place. def create_u2f - @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges]) + @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges]) if @u2f_registration.persisted? session.delete(:challenges) - redirect_to profile_account_path, notice: "Your U2F device was registered!" + redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!" else @qr_code = build_qr_code setup_u2f_registration @@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController # Actual communication is performed using a Javascript API def setup_u2f_registration @u2f_registration ||= U2fRegistration.new - @registration_key_handles = current_user.u2f_registrations.pluck(:key_handle) + @u2f_registrations = current_user.u2f_registrations u2f = U2F::U2F.new(u2f_app_id) registration_requests = u2f.registration_requests - sign_requests = u2f.authentication_requests(@registration_key_handles) + sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle)) session[:challenges] = registration_requests.map(&:challenge) gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id, register_requests: registration_requests, sign_requests: sign_requests }) end + + def u2f_registration_params + params.require(:u2f_registration).permit(:device_response, :name) + end end diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb new file mode 100644 index 00000000000..c02fe85c3cc --- /dev/null +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -0,0 +1,7 @@ +class Profiles::U2fRegistrationsController < Profiles::ApplicationController + def destroy + u2f_registration = current_user.u2f_registrations.find(params[:id]) + u2f_registration.destroy + redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device." + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index c5fa756d02b..f71e0a1302b 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -73,7 +73,8 @@ class ProfilesController < Profiles::ApplicationController :skype, :twitter, :username, - :website_url + :website_url, + :organization ) end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 996909a28c6..b2ff36f6538 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -83,10 +83,11 @@ class Projects::ApplicationController < ApplicationController end def apply_diff_view_cookie! + @show_changes_tab = params[:view].present? cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present? end def builds_enabled - return render_404 unless @project.builds_enabled? + return render_404 unless @project.feature_available?(:builds, current_user) end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 7241949393b..59222637961 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,22 +1,25 @@ class Projects::ArtifactsController < Projects::ApplicationController + include ExtractsPath + layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] + before_action :extract_ref_name_and_path before_action :validate_artifacts! def download - unless artifacts_file.file_storage? - return redirect_to artifacts_file.url + if artifacts_file.file_storage? + send_file artifacts_file.path, disposition: 'attachment' + else + redirect_to artifacts_file.url end - - send_file artifacts_file.path, disposition: 'attachment' end def browse directory = params[:path] ? "#{params[:path]}/" : '' @entry = build.artifacts_metadata_entry(directory) - return render_404 unless @entry.exists? + render_404 unless @entry.exists? end def file @@ -34,14 +37,41 @@ class Projects::ArtifactsController < Projects::ApplicationController redirect_to namespace_project_build_path(project.namespace, project, build) end + def latest_succeeded + target_path = artifacts_action_path(@path, project, build) + + if target_path + redirect_to(target_path) + else + render_404 + end + end + private + def extract_ref_name_and_path + return unless params[:ref_name_and_path] + + @ref_name, @path = extract_ref(params[:ref_name_and_path]) + end + def validate_artifacts! - render_404 unless build.artifacts? + render_404 unless build && build.artifacts? end def build - @build ||= project.builds.find_by!(id: params[:build_id]) + @build ||= build_from_id || build_from_ref + end + + def build_from_id + project.builds.find_by(id: params[:build_id]) if params[:build_id] + end + + def build_from_ref + return unless @ref_name + + builds = project.latest_successful_builds_for(@ref_name) + builds.find_by(name: params[:job]) end def artifacts_file diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb index 5962f74c39b..ada7db3c552 100644 --- a/app/controllers/projects/avatars_controller.rb +++ b/app/controllers/projects/avatars_controller.rb @@ -4,7 +4,7 @@ class Projects::AvatarsController < Projects::ApplicationController before_action :authorize_admin_project!, only: [:destroy] def show - @blob = @repository.blob_at_branch('master', @project.avatar_in_git) + @blob = @repository.blob_at_branch(@repository.root_ref, @project.avatar_in_git) if @blob headers['X-Content-Type-Options'] = 'nosniff' diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index a9f482c8787..6c25cd83a24 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -4,12 +4,26 @@ class Projects::BadgesController < Projects::ApplicationController before_action :no_cache_headers, except: [:index] def build - badge = Gitlab::Badge::Build.new(project, params[:ref]) + build_status = Gitlab::Badge::Build::Status + .new(project, params[:ref]) + render_badge build_status + end + + def coverage + coverage_report = Gitlab::Badge::Coverage::Report + .new(project, params[:ref], params[:job]) + + render_badge coverage_report + end + + private + + def render_badge(badge) respond_to do |format| format.html { render_404 } format.svg do - send_data(badge.data, type: badge.type, disposition: 'inline') + render 'badge', locals: { badge: badge.template } end end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index eda3727a28d..b78cc6585ba 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -17,6 +17,7 @@ class Projects::BlobController < Projects::ApplicationController before_action :require_branch_head, only: [:edit, :update] before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff + before_action :set_last_commit_sha, only: [:edit, :update] def new commit unless @repository.empty? @@ -33,17 +34,11 @@ class Projects::BlobController < Projects::ApplicationController end def edit - @last_commit = Gitlab::Git::Commit.last_for_path(@repository, @ref, @path).sha blob.load_all_data!(@repository) end def update - if params[:file_path].present? - @previous_path = @path - @path = params[:file_path] - @commit_params[:file_path] = @path - end - + @path = params[:file_path] if params[:file_path].present? after_edit_path = if from_merge_request && @target_branch == @ref diffs_namespace_project_merge_request_path(from_merge_request.target_project.namespace, from_merge_request.target_project, from_merge_request) + @@ -55,6 +50,10 @@ class Projects::BlobController < Projects::ApplicationController create_commit(Files::UpdateService, success_path: after_edit_path, failure_view: :edit, failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + + rescue Files::UpdateService::FileChangedError + @conflict = true + render :edit end def preview @@ -76,6 +75,8 @@ class Projects::BlobController < Projects::ApplicationController end def diff + apply_diff_view_cookie! + @form = UnfoldForm.new(params) @lines = Gitlab::Highlight.highlight_lines(repository, @ref, @path) @lines = @lines[@form.since - 1..@form.to - 1] @@ -137,6 +138,8 @@ class Projects::BlobController < Projects::ApplicationController params[:file_name] = params[:file].original_filename end File.join(@path, params[:file_name]) + elsif params[:file_path].present? + params[:file_path] else @path end @@ -149,8 +152,10 @@ class Projects::BlobController < Projects::ApplicationController @commit_params = { file_path: @file_path, commit_message: params[:commit_message], + previous_path: @path, file_content: params[:content], - file_content_encoding: params[:encoding] + file_content_encoding: params[:encoding], + last_commit_sha: params[:last_commit_sha] } end @@ -159,4 +164,9 @@ class Projects::BlobController < Projects::ApplicationController render nothing: true end end + + def set_last_commit_sha + @last_commit_sha = Gitlab::Git::Commit. + last_for_path(@repository, @ref, @path).sha + end end diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb new file mode 100644 index 00000000000..dad38fff6b9 --- /dev/null +++ b/app/controllers/projects/boards/application_controller.rb @@ -0,0 +1,15 @@ +module Projects + module Boards + class ApplicationController < Projects::ApplicationController + respond_to :json + + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + private + + def record_not_found(exception) + render json: { error: exception.message }, status: :not_found + end + end + end +end diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb new file mode 100644 index 00000000000..71eb56aed0b --- /dev/null +++ b/app/controllers/projects/boards/issues_controller.rb @@ -0,0 +1,83 @@ +module Projects + module Boards + class IssuesController < Boards::ApplicationController + before_action :authorize_read_issue!, only: [:index] + before_action :authorize_create_issue!, only: [:create] + before_action :authorize_update_issue!, only: [:update] + + def index + issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute + issues = issues.page(params[:page]) + + render json: { + issues: serialize_as_json(issues), + size: issues.total_count + } + end + + def create + service = ::Boards::Issues::CreateService.new(project, current_user, issue_params) + issue = service.execute + + if issue.valid? + render json: serialize_as_json(issue) + else + render json: issue.errors, status: :unprocessable_entity + end + end + + def update + service = ::Boards::Issues::MoveService.new(project, current_user, move_params) + + if service.execute(issue) + head :ok + else + head :unprocessable_entity + end + end + + private + + def issue + @issue ||= + IssuesFinder.new(current_user, project_id: project.id) + .execute + .where(iid: params[:id]) + .first! + end + + def authorize_read_issue! + return render_403 unless can?(current_user, :read_issue, project) + end + + def authorize_create_issue! + return render_403 unless can?(current_user, :admin_issue, project) + end + + def authorize_update_issue! + return render_403 unless can?(current_user, :update_issue, issue) + end + + def filter_params + params.merge(board_id: params[:board_id], id: params[:list_id]) + end + + def move_params + params.permit(:board_id, :id, :from_list_id, :to_list_id) + end + + def issue_params + params.require(:issue).permit(:title).merge(board_id: params[:board_id], list_id: params[:list_id], request: request) + end + + def serialize_as_json(resource) + resource.as_json( + only: [:iid, :title, :confidential], + include: { + assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + labels: { only: [:id, :title, :description, :color, :priority], methods: [:text_color] } + }) + end + end + end +end diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb new file mode 100644 index 00000000000..76ae41319c4 --- /dev/null +++ b/app/controllers/projects/boards/lists_controller.rb @@ -0,0 +1,85 @@ +module Projects + module Boards + class ListsController < Boards::ApplicationController + before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate] + before_action :authorize_read_list!, only: [:index] + + def index + render json: serialize_as_json(board.lists) + end + + def create + list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute(board) + + if list.valid? + render json: serialize_as_json(list) + else + render json: list.errors, status: :unprocessable_entity + end + end + + def update + list = board.lists.movable.find(params[:id]) + service = ::Boards::Lists::MoveService.new(project, current_user, move_params) + + if service.execute(list) + head :ok + else + head :unprocessable_entity + end + end + + def destroy + list = board.lists.destroyable.find(params[:id]) + service = ::Boards::Lists::DestroyService.new(project, current_user) + + if service.execute(list) + head :ok + else + head :unprocessable_entity + end + end + + def generate + service = ::Boards::Lists::GenerateService.new(project, current_user) + + if service.execute(board) + render json: serialize_as_json(board.lists.movable) + else + head :unprocessable_entity + end + end + + private + + def authorize_admin_list! + return render_403 unless can?(current_user, :admin_list, project) + end + + def authorize_read_list! + return render_403 unless can?(current_user, :read_list, project) + end + + def board + @board ||= project.boards.find(params[:board_id]) + end + + def list_params + params.require(:list).permit(:label_id) + end + + def move_params + params.require(:list).permit(:position) + end + + def serialize_as_json(resource) + resource.as_json( + only: [:id, :list_type, :position], + methods: [:title], + include: { + label: { only: [:id, :title, :description, :color, :priority] } + }) + end + end + end +end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb new file mode 100644 index 00000000000..808affa4f98 --- /dev/null +++ b/app/controllers/projects/boards_controller.rb @@ -0,0 +1,37 @@ +class Projects::BoardsController < Projects::ApplicationController + include IssuableCollections + + before_action :authorize_read_board!, only: [:index, :show] + + def index + @boards = ::Boards::ListService.new(project, current_user).execute + + respond_to do |format| + format.html + format.json do + render json: serialize_as_json(@boards) + end + end + end + + def show + @board = project.boards.find(params[:id]) + + respond_to do |format| + format.html + format.json do + render json: serialize_as_json(@board) + end + end + end + + private + + def authorize_read_board! + return access_denied! unless can?(current_user, :read_board, project) + end + + def serialize_as_json(resource) + resource.as_json(only: [:id]) + end +end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index e926043f3eb..2de8ada3e29 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -1,12 +1,13 @@ class Projects::BranchesController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper + include SortingHelper # Authorize before_action :require_non_empty_project before_action :authorize_download_code! before_action :authorize_push_code!, only: [:new, :create, :destroy] def index - @sort = params[:sort].presence || 'name' + @sort = params[:sort].presence || sort_value_name @branches = BranchesFinder.new(@repository, params).execute @branches = Kaminari.paginate_array(@branches).page(params[:page]) @@ -14,6 +15,13 @@ class Projects::BranchesController < Projects::ApplicationController diverging_commit_counts = repository.diverging_commit_counts(branch) [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max end + + respond_to do |format| + format.html + format.json do + render json: @repository.branch_names + end + end end def recent diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 553b62741a5..3b2e35a7a05 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -6,7 +6,7 @@ class Projects::BuildsController < Projects::ApplicationController def index @scope = params[:scope] - @all_builds = project.builds + @all_builds = project.builds.relevant @builds = @all_builds.order('created_at DESC') @builds = case @scope @@ -35,7 +35,11 @@ class Projects::BuildsController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: @build.to_json(methods: :trace_html) + render json: { + id: @build.id, + status: @build.status, + trace_html: @build.trace_html + } end end end @@ -74,12 +78,12 @@ class Projects::BuildsController < Projects::ApplicationController def erase @build.erase(erased_by: current_user) redirect_to namespace_project_build_path(project.namespace, project, @build), - notice: "Build has been sucessfully erased!" + notice: "Build has been successfully erased!" end def raw - if @build.has_trace? - send_file @build.path_to_trace, type: 'text/plain; charset=utf-8', disposition: 'inline' + if @build.has_trace_file? + send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline' else render_404 end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index fdfe7c65b7b..cdfc1ba7b92 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -10,10 +10,11 @@ class Projects::CommitController < Projects::ApplicationController before_action :require_non_empty_project before_action :authorize_download_code!, except: [:cancel_builds, :retry_builds] before_action :authorize_update_build!, only: [:cancel_builds, :retry_builds] + before_action :authorize_read_pipeline!, only: [:pipelines] before_action :authorize_read_commit_status!, only: [:builds] before_action :commit - before_action :define_commit_vars, only: [:show, :diff_for_path, :builds] - before_action :define_status_vars, only: [:show, :builds] + before_action :define_commit_vars, only: [:show, :diff_for_path, :builds, :pipelines] + before_action :define_status_vars, only: [:show, :builds, :pipelines] before_action :define_note_vars, only: [:show, :diff_for_path] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] @@ -31,6 +32,9 @@ class Projects::CommitController < Projects::ApplicationController render_diff_for_path(@commit.diffs(diff_options)) end + def pipelines + end + def builds end @@ -93,11 +97,7 @@ class Projects::CommitController < Projects::ApplicationController end def commit - @commit ||= @project.commit(params[:id]) - end - - def pipelines - @pipelines ||= project.pipelines.where(sha: commit.sha) + @noteable = @commit ||= @project.commit(params[:id]) end def ci_builds @@ -134,8 +134,9 @@ class Projects::CommitController < Projects::ApplicationController end def define_status_vars - @statuses = CommitStatus.where(pipeline: pipelines) - @builds = Ci::Build.where(pipeline: pipelines) + @ci_pipelines = project.pipelines.where(sha: commit.sha) + @statuses = CommitStatus.where(pipeline: @ci_pipelines).relevant + @builds = Ci::Build.where(pipeline: @ci_pipelines).relevant end def assign_change_commit_vars(mr_source_branch) diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb new file mode 100644 index 00000000000..16a7b1fc6e2 --- /dev/null +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -0,0 +1,67 @@ +class Projects::CycleAnalyticsController < Projects::ApplicationController + include ActionView::Helpers::DateHelper + include ActionView::Helpers::TextHelper + + before_action :authorize_read_cycle_analytics! + + def show + @cycle_analytics = CycleAnalytics.new(@project, from: parse_start_date) + + respond_to do |format| + format.html + format.json { render json: cycle_analytics_json } + end + end + + private + + def parse_start_date + case cycle_analytics_params[:start_date] + when '30' then 30.days.ago + when '90' then 90.days.ago + else 90.days.ago + end + end + + def cycle_analytics_params + return {} unless params[:cycle_analytics].present? + + { start_date: params[:cycle_analytics][:start_date] } + end + + def cycle_analytics_json + cycle_analytics_view_data = [[:issue, "Issue", "Time before an issue gets scheduled"], + [:plan, "Plan", "Time before an issue starts implementation"], + [:code, "Code", "Time until first merge request"], + [:test, "Test", "Total test time for all commits/merges"], + [:review, "Review", "Time between merge request creation and merge/close"], + [:staging, "Staging", "From merge request merge until deploy to production"], + [:production, "Production", "From issue creation until deploy to production"]] + + stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_description)| + value = @cycle_analytics.send(stage_method).presence + + stats << { + title: stage_text, + description: stage_description, + value: value && !value.zero? ? distance_of_time_in_words(value) : nil + } + stats + end + + issues = @cycle_analytics.summary.new_issues + commits = @cycle_analytics.summary.commits + deploys = @cycle_analytics.summary.deploys + + summary = [ + { title: "New Issue".pluralize(issues), value: issues }, + { title: "Commit".pluralize(commits), value: commits }, + { title: "Deploy".pluralize(deploys), value: deploys } + ] + + { + summary: summary, + stats: stats + } + end +end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 83d5ced9be8..529e0aa2d33 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -12,8 +12,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def new - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def create @@ -21,19 +20,16 @@ class Projects::DeployKeysController < Projects::ApplicationController set_index_vars if @key.valid? && @project.deploy_keys << @key - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) else render "index" end end def enable - @key = accessible_keys.find(params[:id]) - @project.deploy_keys << @key + Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def disable @@ -45,9 +41,9 @@ class Projects::DeployKeysController < Projects::ApplicationController protected def set_index_vars - @enabled_keys ||= @project.deploy_keys + @enabled_keys ||= @project.deploy_keys - @available_keys ||= accessible_keys - @enabled_keys + @available_keys ||= current_user.accessible_deploy_keys - @enabled_keys @available_project_keys ||= current_user.project_deploy_keys - @enabled_keys @available_public_keys ||= DeployKey.are_public - @enabled_keys @@ -56,10 +52,6 @@ class Projects::DeployKeysController < Projects::ApplicationController @available_public_keys -= @available_project_keys end - def accessible_keys - @accessible_keys ||= current_user.accessible_deploy_keys - end - def deploy_key_params params.require(:deploy_key).permit(:key, :title) end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb new file mode 100644 index 00000000000..d174e1145a7 --- /dev/null +++ b/app/controllers/projects/discussions_controller.rb @@ -0,0 +1,43 @@ +class Projects::DiscussionsController < Projects::ApplicationController + before_action :module_enabled + before_action :merge_request + before_action :discussion + before_action :authorize_resolve_discussion! + + def resolve + discussion.resolve!(current_user) + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + + render json: { + resolved_by: discussion.resolved_by.try(:name), + discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) + } + end + + def unresolve + discussion.unresolve! + + render json: { + discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) + } + end + + private + + def merge_request + @merge_request ||= @project.merge_requests.find_by!(iid: params[:merge_request_id]) + end + + def discussion + @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404 + end + + def authorize_resolve_discussion! + access_denied! unless discussion.can_resolve?(current_user) + end + + def module_enabled + render_404 unless @project.feature_available?(:merge_requests, current_user) + end +end diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb new file mode 100644 index 00000000000..383e184d796 --- /dev/null +++ b/app/controllers/projects/git_http_client_controller.rb @@ -0,0 +1,156 @@ +# This file should be identical in GitLab Community Edition and Enterprise Edition + +class Projects::GitHttpClientController < Projects::ApplicationController + include ActionController::HttpAuthentication::Basic + include KerberosSpnegoHelper + + attr_reader :authentication_result + + delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true + + alias_method :user, :actor + + # Git clients will not know what authenticity token to send along + skip_before_action :verify_authenticity_token + skip_before_action :repository + before_action :authenticate_user + before_action :ensure_project_found! + + private + + def authenticate_user + @authentication_result = Gitlab::Auth::Result.new + + if project && project.public? && download_request? + return # Allow access + end + + if allow_basic_auth? && basic_auth_provided? + login, password = user_name_and_password(request) + + if handle_basic_authentication(login, password) + return # Allow access + end + elsif allow_kerberos_spnego_auth? && spnego_provided? + kerberos_user = find_kerberos_user + + if kerberos_user + @authentication_result = Gitlab::Auth::Result.new( + kerberos_user, nil, :kerberos, Gitlab::Auth.full_authentication_abilities) + + send_final_spnego_response + return # Allow access + end + end + + send_challenges + render plain: "HTTP Basic: Access denied\n", status: 401 + rescue Gitlab::Auth::MissingPersonalTokenError + render_missing_personal_token + end + + def basic_auth_provided? + has_basic_credentials?(request) + end + + def send_challenges + challenges = [] + challenges << 'Basic realm="GitLab"' if allow_basic_auth? + challenges << spnego_challenge if allow_kerberos_spnego_auth? + headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? + end + + def ensure_project_found! + render_not_found if project.blank? + end + + def project + return @project if defined?(@project) + + project_id, _ = project_id_with_suffix + if project_id.blank? + @project = nil + else + @project = Project.find_with_namespace("#{params[:namespace_id]}/#{project_id}") + end + end + + # This method returns two values so that we can parse + # params[:project_id] (untrusted input!) in exactly one place. + def project_id_with_suffix + id = params[:project_id] || '' + + %w[.wiki.git .git].each do |suffix| + if id.end_with?(suffix) + # Be careful to only remove the suffix from the end of 'id'. + # Accidentally removing it from the middle is how security + # vulnerabilities happen! + return [id.slice(0, id.length - suffix.length), suffix] + end + end + + # Something is wrong with params[:project_id]; do not pass it on. + [nil, nil] + end + + def render_missing_personal_token + render plain: "HTTP Basic: Access denied\n" \ + "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \ + "You can generate one at #{profile_personal_access_tokens_url}", + status: 401 + end + + def repository + _, suffix = project_id_with_suffix + if suffix == '.wiki.git' + project.wiki.repository + else + project.repository + end + end + + def render_not_found + render plain: 'Not Found', status: :not_found + end + + def handle_basic_authentication(login, password) + @authentication_result = Gitlab::Auth.find_for_git_client( + login, password, project: project, ip: request.ip) + + return false unless @authentication_result.success? + + if download_request? + authentication_has_download_access? + else + authentication_has_upload_access? + end + end + + def ci? + authentication_result.ci?(project) + end + + def lfs_deploy_token? + authentication_result.lfs_deploy_token?(project) + end + + def authentication_has_download_access? + has_authentication_ability?(:download_code) || has_authentication_ability?(:build_download_code) + end + + def authentication_has_upload_access? + has_authentication_ability?(:push_code) + end + + def has_authentication_ability?(capability) + (authentication_abilities || []).include?(capability) + end + + def authentication_project + authentication_result.project + end + + def verify_workhorse_api! + Gitlab::Workhorse.verify_api_request!(request.headers) + end +end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 40a8b7940d9..662d38b10a5 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -1,16 +1,7 @@ # This file should be identical in GitLab Community Edition and Enterprise Edition -class Projects::GitHttpController < Projects::ApplicationController - include ActionController::HttpAuthentication::Basic - include KerberosSpnegoHelper - - attr_reader :user - - # Git clients will not know what authenticity token to send along - skip_before_action :verify_authenticity_token - skip_before_action :repository - before_action :authenticate_user - before_action :ensure_project_found! +class Projects::GitHttpController < Projects::GitHttpClientController + before_action :verify_workhorse_api! # GET /foo/bar.git/info/refs?service=git-upload-pack (git pull) # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) @@ -20,9 +11,9 @@ class Projects::GitHttpController < Projects::ApplicationController elsif receive_pack? && receive_pack_allowed? render_ok elsif http_blocked? - render_not_allowed + render_http_not_allowed else - render_not_found + render_denied end end @@ -31,7 +22,7 @@ class Projects::GitHttpController < Projects::ApplicationController if upload_pack? && upload_pack_allowed? render_ok else - render_not_found + render_denied end end @@ -40,87 +31,14 @@ class Projects::GitHttpController < Projects::ApplicationController if receive_pack? && receive_pack_allowed? render_ok else - render_not_found + render_denied end end private - def authenticate_user - if project && project.public? && upload_pack? - return # Allow access - end - - if allow_basic_auth? && basic_auth_provided? - login, password = user_name_and_password(request) - auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip) - - if auth_result.type == :ci && upload_pack? - @ci = true - elsif auth_result.type == :oauth && !upload_pack? - # Not allowed - else - @user = auth_result.user - end - - if ci? || user - return # Allow access - end - elsif allow_kerberos_spnego_auth? && spnego_provided? - @user = find_kerberos_user - - if user - send_final_spnego_response - return # Allow access - end - end - - send_challenges - render plain: "HTTP Basic: Access denied\n", status: 401 - end - - def basic_auth_provided? - has_basic_credentials?(request) - end - - def send_challenges - challenges = [] - challenges << 'Basic realm="GitLab"' if allow_basic_auth? - challenges << spnego_challenge if allow_kerberos_spnego_auth? - headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? - end - - def ensure_project_found! - render_not_found if project.blank? - end - - def project - return @project if defined?(@project) - - project_id, _ = project_id_with_suffix - if project_id.blank? - @project = nil - else - @project = Project.find_with_namespace("#{params[:namespace_id]}/#{project_id}") - end - end - - # This method returns two values so that we can parse - # params[:project_id] (untrusted input!) in exactly one place. - def project_id_with_suffix - id = params[:project_id] || '' - - %w[.wiki.git .git].each do |suffix| - if id.end_with?(suffix) - # Be careful to only remove the suffix from the end of 'id'. - # Accidentally removing it from the middle is how security - # vulnerabilities happen! - return [id.slice(0, id.length - suffix.length), suffix] - end - end - - # Something is wrong with params[:project_id]; do not pass it on. - [nil, nil] + def download_request? + upload_pack? end def upload_pack? @@ -140,50 +58,41 @@ class Projects::GitHttpController < Projects::ApplicationController end def render_ok + set_workhorse_internal_api_content_type render json: Gitlab::Workhorse.git_http_ok(repository, user) end - def repository - _, suffix = project_id_with_suffix - if suffix == '.wiki.git' - project.wiki.repository - else - project.repository - end + def render_http_not_allowed + render plain: access_check.message, status: :forbidden end - def render_not_found - render plain: 'Not Found', status: :not_found - end - - def render_not_allowed - render plain: download_access.message, status: :forbidden - end - - def ci? - @ci.present? + def render_denied + if user && user.can?(:read_project, project) + render plain: 'Access denied', status: :forbidden + else + # Do not leak information about project existence + render_not_found + end end def upload_pack_allowed? return false unless Gitlab.config.gitlab_shell.upload_pack if user - download_access.allowed? + access_check.allowed? else ci? || project.public? end end def access - return @access if defined?(@access) - - @access = Gitlab::GitAccess.new(user, project, 'http') + @access ||= Gitlab::GitAccess.new(user, project, 'http', authentication_abilities: authentication_abilities) end - def download_access - return @download_access if defined?(@download_access) - - @download_access = access.check('git-upload-pack') + def access_check + # Use the magic string '_any' to indicate we do not know what the + # changes are. This is also what gitlab-shell does. + @access_check ||= access.check(git_command, '_any') end def http_blocked? @@ -193,8 +102,6 @@ class Projects::GitHttpController < Projects::ApplicationController def receive_pack_allowed? return false unless Gitlab.config.gitlab_shell.receive_pack - # Skip user authorization on upload request. - # It will be done by the pre-receive hook in the repository. - user.present? + access_check.allowed? end end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 092ef32e6e3..923e7340e69 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -38,12 +38,12 @@ class Projects::GraphsController < Projects::ApplicationController @languages = @languages.map do |language| name, share = language - color = Digest::SHA256.hexdigest(name)[0...6] + color = Linguist::Language[name].color || "##{Digest::SHA256.hexdigest(name)[0...6]}" { value: (share.to_f * 100 / total).round(2), label: name, - color: "##{color}", - highlight: "##{color}" + color: color, + highlight: color } end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 606552fa853..7a7475a7345 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -4,15 +4,25 @@ class Projects::GroupLinksController < Projects::ApplicationController def index @group_links = project.project_group_links.all + + @skip_groups = @group_links.pluck(:group_id) + @skip_groups << project.group.try(:id) end def create - group = Group.find(params[:link_group_id]) - return render_404 unless can?(current_user, :read_group, group) + group = Group.find(params[:link_group_id]) if params[:link_group_id].present? + + if group + return render_404 unless can?(current_user, :read_group, group) - project.project_group_links.create( - group: group, group_access: params[:link_group_access] - ) + project.project_group_links.create( + group: group, + group_access: params[:link_group_access], + expires_at: params[:expires_at] + ) + else + flash[:alert] = 'Please select a group.' + end redirect_to namespace_project_group_links_path(project.namespace, project) end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index a60027ff477..0ae8ff98009 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -56,8 +56,10 @@ class Projects::HooksController < Projects::ApplicationController def hook_params params.require(:hook).permit( :build_events, + :pipeline_events, :enable_ssl_verification, :issues_events, + :confidential_issues_events, :merge_requests_events, :note_events, :push_events, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 660e0eba06f..96041b07647 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -4,6 +4,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableActions include ToggleAwardEmoji include IssuableCollections + include SpammableActions before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled @@ -19,24 +20,12 @@ class Projects::IssuesController < Projects::ApplicationController # Allow modify issue before_action :authorize_update_issue!, only: [:edit, :update] - # Allow issues bulk update - before_action :authorize_admin_issues!, only: [:bulk_update] - respond_to :html def index - terms = params['issue_search'] @issues = issues_collection - - if terms.present? - if terms =~ /\A#(\d+)\z/ - @issues = @issues.where(iid: $1) - else - @issues = @issues.full_search(terms) - end - end - @issues = @issues.page(params[:page]) + @labels = @project.labels.where(title: params[:label_name]) respond_to do |format| @@ -65,7 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController end def show - raw_notes = @issue.notes_with_associations.fresh + raw_notes = @issue.notes.inc_relations_for_view.fresh @notes = Banzai::NoteRenderer. render(raw_notes, @project, current_user, @path, @project_wiki, @ref) @@ -124,6 +113,10 @@ class Projects::IssuesController < Projects::ApplicationController render json: @issue.to_json(include: { milestone: {}, assignee: { methods: :avatar_url }, labels: { methods: :text_color } }) end end + + rescue ActiveRecord::StaleObjectError + @conflict = true + render :edit end def referenced_merge_requests @@ -163,28 +156,16 @@ class Projects::IssuesController < Projects::ApplicationController end end - def bulk_update - result = Issues::BulkUpdateService.new(project, current_user, bulk_update_params).execute - - respond_to do |format| - format.json do - render json: { notice: "#{result[:count]} issues updated" } - end - end - end - protected def issue - @issue ||= begin - @project.issues.find_by!(iid: params[:id]) - rescue ActiveRecord::RecordNotFound - redirect_old - end + # The Sortable default scope causes performance issues when used with find_by + @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old end alias_method :subscribable_resource, :issue alias_method :issuable, :issue alias_method :awardable, :issue + alias_method :spammable, :issue def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) @@ -199,7 +180,7 @@ class Projects::IssuesController < Projects::ApplicationController end def module_enabled - return render_404 unless @project.issues_enabled && @project.default_issues_tracker? + return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker? end def redirect_to_external_issue_tracker @@ -210,7 +191,7 @@ class Projects::IssuesController < Projects::ApplicationController if action_name == 'new' redirect_to external.new_issue_path else - redirect_to external.issues_url + redirect_to external.project_path end end @@ -224,7 +205,6 @@ class Projects::IssuesController < Projects::ApplicationController if issue redirect_to issue_path(issue) - return else raise ActiveRecord::RecordNotFound.new end @@ -233,20 +213,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, label_ids: [] - ) - end - - def bulk_update_params - params.require(:update).permit( - :issues_ids, - :assignee_id, - :milestone_id, - :state_event, - :subscription_event, - label_ids: [], - add_label_ids: [], - remove_label_ids: [] + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] ) end end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 0ca675623e5..a6626df4826 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -30,9 +30,15 @@ class Projects::LabelsController < Projects::ApplicationController @label = @project.labels.create(label_params) if @label.valid? - redirect_to namespace_project_labels_path(@project.namespace, @project) + respond_to do |format| + format.html { redirect_to namespace_project_labels_path(@project.namespace, @project) } + format.json { render json: @label } + end else - render 'new' + respond_to do |format| + format.html { render 'new' } + format.json { render json: { message: @label.errors.messages }, status: 400 } + end end end @@ -99,7 +105,7 @@ class Projects::LabelsController < Projects::ApplicationController protected def module_enabled - unless @project.issues_enabled || @project.merge_requests_enabled + unless @project.feature_available?(:issues, current_user) || @project.feature_available?(:merge_requests, current_user) return render_404 end end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb new file mode 100644 index 00000000000..ece49dcd922 --- /dev/null +++ b/app/controllers/projects/lfs_api_controller.rb @@ -0,0 +1,94 @@ +class Projects::LfsApiController < Projects::GitHttpClientController + include LfsHelper + + before_action :require_lfs_enabled! + before_action :lfs_check_access!, except: [:deprecated] + + def batch + unless objects.present? + render_lfs_not_found + return + end + + if download_request? + render json: { objects: download_objects! } + elsif upload_request? + render json: { objects: upload_objects! } + else + raise "Never reached" + end + end + + def deprecated + render( + json: { + message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', + documentation_url: "#{Gitlab.config.gitlab.url}/help", + }, + status: 501 + ) + end + + private + + def objects + @objects ||= (params[:objects] || []).to_a + end + + def existing_oids + @existing_oids ||= begin + storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid) + end + end + + def download_objects! + objects.each do |object| + if existing_oids.include?(object[:oid]) + object[:actions] = download_actions(object) + else + object[:error] = { + code: 404, + message: "Object does not exist on the server or you don't have permissions to access it", + } + end + end + objects + end + + def upload_objects! + objects.each do |object| + object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid]) + end + objects + end + + def download_actions(object) + { + download: { + href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}", + header: { + Authorization: request.headers['Authorization'] + }.compact + } + } + end + + def upload_actions(object) + { + upload: { + href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", + header: { + Authorization: request.headers['Authorization'] + }.compact + } + } + end + + def download_request? + params[:operation] == 'download' + end + + def upload_request? + params[:operation] == 'upload' + end +end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb new file mode 100644 index 00000000000..9005b104e90 --- /dev/null +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -0,0 +1,87 @@ +class Projects::LfsStorageController < Projects::GitHttpClientController + include LfsHelper + + before_action :require_lfs_enabled! + before_action :lfs_check_access! + before_action :verify_workhorse_api!, only: [:upload_authorize] + + def download + lfs_object = LfsObject.find_by_oid(oid) + unless lfs_object && lfs_object.file.exists? + render_lfs_not_found + return + end + + send_file lfs_object.file.path, content_type: "application/octet-stream" + end + + def upload_authorize + set_workhorse_internal_api_content_type + render json: Gitlab::Workhorse.lfs_upload_ok(oid, size) + end + + def upload_finalize + unless tmp_filename + render_lfs_forbidden + return + end + + if store_file(oid, size, tmp_filename) + head 200 + else + render plain: 'Unprocessable entity', status: 422 + end + end + + private + + def download_request? + action_name == 'download' + end + + def upload_request? + %w[upload_authorize upload_finalize].include? action_name + end + + def oid + params[:oid].to_s + end + + def size + params[:size].to_i + end + + def tmp_filename + name = request.headers['X-Gitlab-Lfs-Tmp'] + return if name.include?('/') + return unless oid.present? && name.start_with?(oid) + name + end + + def store_file(oid, size, tmp_file) + # Define tmp_file_path early because we use it in "ensure" + tmp_file_path = File.join("#{Gitlab.config.lfs.storage_path}/tmp/upload", tmp_file) + + object = LfsObject.find_or_create_by(oid: oid, size: size) + file_exists = object.file.exists? || move_tmp_file_to_storage(object, tmp_file_path) + file_exists && link_to_project(object) + ensure + FileUtils.rm_f(tmp_file_path) + end + + def move_tmp_file_to_storage(object, path) + File.open(path) do |f| + object.file = f + end + + object.file.store! + object.save + end + + def link_to_project(object) + if object && !object.projects.exists?(storage_project.id) + object.projects << storage_project + object.save + end + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 55ba3e80b9d..1740888904d 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,15 +9,18 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :module_enabled before_action :merge_request, only: [ - :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip + :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check, + :ci_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues ] - before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] - before_action :define_show_vars, only: [:show, :diffs, :commits, :builds] + before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines] + before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines] before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check] before_action :define_commit_vars, only: [:diffs] before_action :define_diff_comment_vars, only: [:diffs] - before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds] + before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines] + before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] + before_action :apply_diff_view_cookie!, only: [:new_diffs] + before_action :build_merge_request, only: [:new, :new_diffs] # Allow read any merge_request before_action :authorize_read_merge_request! @@ -28,18 +31,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController # Allow modify merge_request before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort] - def index - terms = params['issue_search'] - @merge_requests = merge_requests_collection + before_action :authenticate_user!, only: [:assign_related_issues] - if terms.present? - if terms =~ /\A[#!](\d+)\z/ - @merge_requests = @merge_requests.where(iid: $1) - else - @merge_requests = @merge_requests.full_search(terms) - end - end + before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts] + def index + @merge_requests = merge_requests_collection @merge_requests = @merge_requests.page(params[:page]) @merge_requests = @merge_requests.preload(:target_project) @@ -81,12 +78,33 @@ class Projects::MergeRequestsController < Projects::ApplicationController def diffs apply_diff_view_cookie! - @merge_request_diff = @merge_request.merge_request_diff + @merge_request_diff = + if params[:diff_id] + @merge_request.merge_request_diffs.find(params[:diff_id]) + else + @merge_request.merge_request_diff + end + + @merge_request_diffs = @merge_request.merge_request_diffs.select_without_diff + @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id } + + if params[:start_sha].present? + @start_sha = params[:start_sha] + @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha } + + unless @start_version + render_404 + end + end respond_to do |format| format.html { define_discussion_vars } format.json do - @diffs = @merge_request.diffs(diff_options) + if @start_sha + compared_diff_version + else + original_diff_version + end render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } end @@ -130,6 +148,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + def conflicts + respond_to do |format| + format.html { define_discussion_vars } + + format.json do + if @merge_request.conflicts_can_be_resolved_in_ui? + render json: @merge_request.conflicts + elsif @merge_request.can_be_merged? + render json: { + message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.', + type: 'error' + } + else + render json: { + message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.', + type: 'error' + } + end + end + end + end + + def resolve_conflicts + return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + + if @merge_request.can_be_merged? + render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' } + return + end + + begin + MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request) + + flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' + + render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) } + rescue Gitlab::Conflict::File::MissingResolution => e + render status: :bad_request, json: { message: e.message } + end + end + def builds respond_to do |format| format.html do @@ -141,29 +200,40 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def new - build_merge_request - @noteable = @merge_request + def pipelines + @pipelines = @merge_request.all_pipelines - @target_branches = if @merge_request.target_project - @merge_request.target_project.repository.branch_names - else - [] - end + respond_to do |format| + format.html do + define_discussion_vars - @target_project = merge_request.target_project - @source_project = merge_request.source_project - @commits = @merge_request.compare_commits.reverse - @commit = @merge_request.diff_head_commit - @base_commit = @merge_request.diff_base_commit - @diffs = @merge_request.diffs(diff_options) if @merge_request.compare - @diff_notes_disabled = true + render 'show' + end + format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } } + end + end - @pipeline = @merge_request.pipeline - @statuses = @pipeline.statuses if @pipeline + def new + define_new_vars + end - @note_counts = Note.where(commit_id: @commits.map(&:id)). - group(:commit_id).count + def new_diffs + respond_to do |format| + format.html do + define_new_vars + render "new" + end + format.json do + @diffs = if @merge_request.can_be_created + @merge_request.diffs(diff_options) + else + [] + end + @diff_notes_disabled = true + + render json: { html: view_to_html_string('projects/merge_requests/_new_diffs', diffs: @diffs) } + end + end end def create @@ -201,10 +271,13 @@ class Projects::MergeRequestsController < Projects::ApplicationController else render "edit" end + rescue ActiveRecord::StaleObjectError + @conflict = true + render :edit end def remove_wip - MergeRequests::UpdateService.new(project, current_user, title: @merge_request.wipless_title).execute(@merge_request) + MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request) redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), notice: "The merge request can now be merged." @@ -239,7 +312,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController TodoService.new.merge_merge_request(merge_request, current_user) - @merge_request.update(merge_params.merge(merge_error: nil)) + @merge_request.update(merge_error: nil) if params[:merge_when_build_succeeds].present? unless @merge_request.pipeline @@ -285,6 +358,25 @@ class Projects::MergeRequestsController < Projects::ApplicationController render layout: false end + def assign_related_issues + result = MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute + + respond_to do |format| + format.html do + case result[:count] + when 0 + flash[:error] = "Failed to assign you issues related to the merge request" + when 1 + flash[:notice] = "1 issue has been assigned to you" + else + flash[:notice] = "#{result[:count]} issues have been assigned to you" + end + + redirect_to(merge_request_path(@merge_request)) + end + end + end + def ci_status pipeline = @merge_request.pipeline if pipeline @@ -313,6 +405,30 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end + def ci_environments_status + environments = + begin + @merge_request.environments.map do |environment| + next unless can?(current_user, :read_environment, environment) + + project = environment.project + deployment = environment.first_deployment_for(@merge_request.diff_head_commit) + + { + id: environment.id, + name: environment.name, + url: namespace_project_environment_path(project.namespace, project, environment), + external_url: environment.external_url, + external_url_formatted: environment.formatted_external_url, + deployed_at: deployment.try(:created_at), + deployed_at_formatted: deployment.try(:formatted_deployment_time) + } + end.compact + end + + render json: environments + end + protected def selected_target_project @@ -324,7 +440,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def merge_request - @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) + @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request @@ -338,22 +454,20 @@ class Projects::MergeRequestsController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_merge_request, @merge_request) end + def authorize_can_resolve_conflicts! + return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user) + end + def module_enabled - return render_404 unless @project.merge_requests_enabled + return render_404 unless @project.feature_available?(:merge_requests, current_user) end def validates_merge_request - # If source project was removed (Ex. mr from fork to origin) - return invalid_mr unless @merge_request.source_project - # Show git not found page # if there is no saved commits between source & target branch if @merge_request.commits.blank? # and if target branch doesn't exist return invalid_mr unless @merge_request.target_branch_exists? - - # or if source branch doesn't exist - return invalid_mr unless @merge_request.source_branch_exists? end end @@ -362,7 +476,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @commits_count = @merge_request.commits.count @pipeline = @merge_request.pipeline - @statuses = @pipeline.statuses if @pipeline + @statuses = @pipeline.statuses.relevant if @pipeline if @merge_request.locked_long_ago? @merge_request.unlock_mr @@ -374,12 +488,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController # :show, :diff, :commits, :builds. but not when request the data through AJAX def define_discussion_vars # Build a note object for comment form - @note = @project.notes.new(noteable: @noteable) + @note = @project.notes.new(noteable: @merge_request) - @discussions = @noteable.mr_and_commit_notes. - inc_author_project_award_emoji. - fresh. - discussions + @discussions = @merge_request.discussions preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) @@ -412,8 +523,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController noteable_id: @merge_request.id } - @use_legacy_diff_notes = !@merge_request.support_new_diff_notes? - @grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions + @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? + @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions Banzai::NoteRenderer.render( @grouped_diff_discussions.values.flat_map(&:notes), @@ -425,8 +536,29 @@ class Projects::MergeRequestsController < Projects::ApplicationController ) end + def define_new_vars + @noteable = @merge_request + + @target_branches = if @merge_request.target_project + @merge_request.target_project.repository.branch_names + else + [] + end + + @target_project = merge_request.target_project + @source_project = merge_request.source_project + @commits = @merge_request.compare_commits.reverse + @commit = @merge_request.diff_head_commit + @base_commit = @merge_request.diff_base_commit + + @pipeline = @merge_request.pipeline + @statuses = @pipeline.statuses.relevant if @pipeline + @note_counts = Note.where(commit_id: @commits.map(&:id)). + group(:commit_id).count + end + def invalid_mr - # Render special view for MR with removed source or target branch + # Render special view for MR with removed target branch render 'invalid' end @@ -434,7 +566,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController params.require(:merge_request).permit( :title, :assignee_id, :source_project_id, :source_branch, :target_project_id, :target_branch, :milestone_id, - :state_event, :description, :task_num, label_ids: []) + :state_event, :description, :task_num, :lock_version, label_ids: []) end def merge_params @@ -454,6 +586,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController def build_merge_request params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) - @merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params).execute + @merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + end + + def compared_diff_version + @diff_notes_disabled = true + @diffs = @merge_request_diff.compare_with(@start_sha).diffs(diff_options) + end + + def original_diff_version + @diff_notes_disabled = !@merge_request_diff.latest? + @diffs = @merge_request_diff.diffs(diff_options) + end + + def close_merge_request_without_source_project + if !@merge_request.source_project && @merge_request.open? + @merge_request.close + end end end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index da2892bfb3f..ff63f22cb5b 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -106,7 +106,7 @@ class Projects::MilestonesController < Projects::ApplicationController end def module_enabled - unless @project.issues_enabled || @project.merge_requests_enabled + unless @project.feature_available?(:issues, current_user) || @project.feature_available?(:merge_requests, current_user) return render_404 end end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 766b7e9cf22..0948ad21649 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_read_note! before_action :authorize_create_note!, only: [:create] before_action :authorize_admin_note!, only: [:update, :destroy] + before_action :authorize_resolve_note!, only: [:resolve, :unresolve] before_action :find_current_user_notes, only: [:index] def index @@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController end end + def resolve + return render_404 unless note.resolvable? + + note.resolve!(current_user) + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable) + + discussion = note.discussion + + render json: { + resolved_by: note.resolved_by.try(:name), + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end + + def unresolve + return render_404 unless note.resolvable? + + note.unresolve! + + discussion = note.discussion + + render json: { + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end + private def note @@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController id: note.id, name: note.name } - elsif note.valid? + elsif note.persisted? Banzai::NoteRenderer.render([note], @project, current_user) attrs = { @@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController } if note.diff_note? - discussion = Discussion.new([note]) + discussion = note.to_discussion attrs.merge!( diff_discussion_html: diff_discussion_html(discussion), @@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController return access_denied! unless can?(current_user, :admin_note, note) end + def authorize_resolve_note! + return access_denied! unless can?(current_user, :resolve_note, note) + end + def note_params params.require(:note).permit( :note, :noteable, :noteable_id, :noteable_type, :project_id, diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 487963fdcd7..371cc3787fb 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -7,11 +7,10 @@ class Projects::PipelinesController < Projects::ApplicationController def index @scope = params[:scope] - all_pipelines = project.pipelines - @pipelines_count = all_pipelines.count - @running_or_pending_count = all_pipelines.running_or_pending.count - @pipelines = PipelinesFinder.new(project).execute(all_pipelines, @scope) - @pipelines = @pipelines.order(id: :desc).page(params[:page]).per(30) + @pipelines = PipelinesFinder.new(project).execute(scope: @scope).page(params[:page]).per(30) + + @running_or_pending_count = PipelinesFinder.new(project).execute(scope: 'running').count + @pipelines_count = PipelinesFinder.new(project).execute.count end def new @@ -19,7 +18,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def create - @pipeline = Ci::CreatePipelineService.new(project, current_user, create_params).execute + @pipeline = Ci::CreatePipelineService.new(project, current_user, create_params).execute(ignore_skip_ci: true, save_on_errors: false) unless @pipeline.persisted? render 'new' return diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 85ba706e5cd..9136633b87a 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -3,7 +3,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show @ref = params[:ref] || @project.default_branch || 'master' - @build_badge = Gitlab::Badge::Build.new(@project, @ref) + + @badges = [Gitlab::Badge::Build::Status, + Gitlab::Badge::Coverage::Report] + + @badges.map! do |badge| + badge.new(@project, @ref).metadata + end end def update diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 3435a118964..f56b256984b 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -29,14 +29,19 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_members = @group_members.order('access_level DESC') end - @requesters = @project.requesters if can?(current_user, :admin_project, @project) + @requesters = AccessRequestsFinder.new(@project).execute(current_user) @project_member = @project.project_members.new @project_group_links = @project.project_group_links end def create - @project.team.add_users(params[:user_ids].split(','), params[:access_level], current_user) + @project.team.add_users( + params[:user_ids].split(','), + params[:access_level], + expires_at: params[:expires_at], + current_user: current_user + ) redirect_to namespace_project_project_members_path(@project.namespace, @project) end @@ -50,10 +55,8 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def destroy - @project_member = @project.members.find_by(id: params[:id]) || - @project.requesters.find_by(id: params[:id]) - - Members::DestroyService.new(@project_member, current_user).execute + Members::DestroyService.new(@project, current_user, params). + execute(:all) respond_to do |format| format.html do @@ -94,7 +97,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController protected def member_params - params.require(:project_member).permit(:user_id, :access_level) + params.require(:project_member).permit(:user_id, :access_level, :expires_at) end # MembershipActions concern diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index d28ec6e2eac..9a438d5512c 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -9,16 +9,16 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def index @protected_branch = @project.protected_branches.new - load_protected_branches_gon_variables + load_gon_index end def create - @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute + @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute if @protected_branch.persisted? redirect_to namespace_project_protected_branches_path(@project.namespace, @project) else load_protected_branches - load_protected_branches_gon_variables + load_gon_index render :index end end @@ -28,7 +28,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController end def update - @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) + @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) if @protected_branch.valid? respond_to do |format| @@ -58,17 +58,23 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def protected_branch_params params.require(:protected_branch).permit(:name, - merge_access_level_attributes: [:access_level], - push_access_level_attributes: [:access_level]) + merge_access_levels_attributes: [:access_level, :id], + push_access_levels_attributes: [:access_level, :id]) end def load_protected_branches @protected_branches = @project.protected_branches.order(:name).page(params[:page]) end - def load_protected_branches_gon_variables - gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }, - push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } }, - merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } }) + def access_levels_options + { + push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, + merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } + } + end + + def load_gon_index + params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } } + gon.push(params.merge(access_levels_options)) end end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 6a227d85f6f..97e6e9471e0 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -20,9 +20,8 @@ class Projects::ServicesController < Projects::ApplicationController def update if @service.update_attributes(service_params[:service]) redirect_to( - edit_namespace_project_service_path(@project.namespace, @project, - @service.to_param, notice: - 'Successfully updated.') + edit_namespace_project_service_path(@project.namespace, @project, @service.to_param), + notice: 'Successfully updated.' ) else render 'edit' diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 6d0a7ee1031..e290a0eadda 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -1,6 +1,8 @@ class Projects::SnippetsController < Projects::ApplicationController + include ToggleAwardEmoji + before_action :module_enabled - before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] + before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji] # Allow read any snippet before_action :authorize_read_project_snippet!, except: [:new, :create, :index] @@ -80,6 +82,7 @@ class Projects::SnippetsController < Projects::ApplicationController def snippet @snippet ||= @project.snippets.find(params[:id]) end + alias_method :awardable, :snippet def authorize_read_project_snippet! return render_404 unless can?(current_user, :read_project_snippet, @snippet) @@ -94,7 +97,7 @@ class Projects::SnippetsController < Projects::ApplicationController end def module_enabled - return render_404 unless @project.snippets_enabled + return render_404 unless @project.feature_available?(:snippets, current_user) end def snippet_params diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 8592579abbd..8fea20cefef 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -1,4 +1,6 @@ class Projects::TagsController < Projects::ApplicationController + include SortingHelper + # Authorize before_action :require_non_empty_project before_action :authorize_download_code! @@ -6,8 +8,10 @@ class Projects::TagsController < Projects::ApplicationController before_action :authorize_admin_project!, only: [:destroy] def index - @sort = params[:sort] || 'name' - @tags = @repository.tags_sorted_by(@sort) + params[:sort] = params[:sort].presence || 'name' + + @sort = params[:sort] + @tags = TagsFinder.new(@repository, params).execute @tags = Kaminari.paginate_array(@tags).page(params[:page]) @releases = project.releases.where(tag: @tags.map(&:name)) @@ -16,6 +20,8 @@ class Projects::TagsController < Projects::ApplicationController def show @tag = @repository.find_tag(params[:id]) + return render_404 unless @tag + @release = @project.releases.find_or_initialize_by(tag: @tag.name) @commit = @repository.commit(@tag.target) end diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb new file mode 100644 index 00000000000..694b468c8d3 --- /dev/null +++ b/app/controllers/projects/templates_controller.rb @@ -0,0 +1,19 @@ +class Projects::TemplatesController < Projects::ApplicationController + before_action :authenticate_user!, :get_template_class + + def show + template = @template_type.find(params[:key], project) + + respond_to do |format| + format.json { render json: template.to_json } + end + end + + private + + def get_template_class + template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access + @template_type = template_types[params[:template_type]] + render json: [], status: 404 unless @template_type + end +end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 607fe9c7fed..177ccf5eec9 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -91,7 +91,7 @@ class Projects::WikisController < Projects::ApplicationController ) end - def markdown_preview + def preview_markdown text = params[:text] ext = Gitlab::ReferenceExtractor.new(@project, current_user) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a6e1aa5ccc1..62916270172 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -125,7 +125,7 @@ class ProjectsController < Projects::ApplicationController def destroy return access_denied! unless can?(current_user, :remove_project, @project) - ::Projects::DestroyService.new(@project, current_user, {}).pending_delete! + ::Projects::DestroyService.new(@project, current_user, {}).async_execute flash[:alert] = "Project '#{@project.name}' will be deleted." redirect_to dashboard_projects_path @@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController end def autocomplete_sources - note_type = params['type'] - note_id = params['type_id'] + noteable = + case params[:type] + when 'Issue' + IssuesFinder.new(current_user, project_id: @project.id). + execute.find_by(iid: params[:type_id]) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: @project.id). + execute.find_by(iid: params[:type_id]) + when 'Commit' + @project.commit(params[:type_id]) + else + nil + end + autocomplete = ::Projects::AutocompleteService.new(@project, current_user) - participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) + participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) @suggestions = { emojis: Gitlab::AwardEmoji.urls, @@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController milestones: autocomplete.milestones, mergerequests: autocomplete.merge_requests, labels: autocomplete.labels, - members: participants + members: participants, + commands: autocomplete.commands(noteable, params[:type]) } respond_to do |format| @@ -238,7 +251,7 @@ class ProjectsController < Projects::ApplicationController } end - def markdown_preview + def preview_markdown text = params[:text] ext = Gitlab::ReferenceExtractor.new(@project, current_user) @@ -290,18 +303,33 @@ class ProjectsController < Projects::ApplicationController end def project_params + project_feature_attributes = + { + project_feature_attributes: + [ + :issues_access_level, :builds_access_level, + :wiki_access_level, :merge_requests_access_level, :snippets_access_level + ] + } + params.require(:project).permit( :name, :path, :description, :issues_tracker, :tag_list, :runners_token, - :issues_enabled, :merge_requests_enabled, :snippets_enabled, :container_registry_enabled, + :container_registry_enabled, :issues_tracker_id, :default_branch, - :wiki_enabled, :visibility_level, :import_url, :last_activity_at, :namespace_id, :avatar, - :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex, - :public_builds, :only_allow_merge_if_build_succeeds, :request_access_enabled + :visibility_level, :import_url, :last_activity_at, :namespace_id, :avatar, + :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex, + :public_builds, :only_allow_merge_if_build_succeeds, :request_access_enabled, + :lfs_enabled, project_feature_attributes ) end def repo_exists? - project.repository_exists? && !project.empty_repo? + project.repository_exists? && !project.empty_repo? && project.repo + + rescue Gitlab::Git::Repository::NoRepository + project.repository.expire_exists_cache + + false end def project_view_files? diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 75b78a49eab..3327f4f2b87 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -33,7 +33,7 @@ class RegistrationsController < Devise::RegistrationsController protected - def build_resource(hash=nil) + def build_resource(hash = nil) super end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 61517d21f9f..d01e0dedf52 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -6,8 +6,6 @@ class SearchController < ApplicationController layout 'search' def show - return if params[:search].nil? || params[:search].blank? - if params[:project_id].present? @project = Project.find_by(id: params[:project_id]) @project = nil unless can?(current_user, :download_code, @project) @@ -18,6 +16,8 @@ class SearchController < ApplicationController @group = nil unless can?(current_user, :read_group, @group) end + return if params[:search].nil? || params[:search].blank? + @search_term = params[:search] @scope = params[:scope] diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index 7271c933b9b..3085ff33aba 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -3,12 +3,19 @@ class SentNotificationsController < ApplicationController def unsubscribe @sent_notification = SentNotification.for(params[:id]) + return render_404 unless @sent_notification && @sent_notification.unsubscribable? + return unsubscribe_and_redirect if current_user || params[:force] + end + private + + def unsubscribe_and_redirect noteable = @sent_notification.noteable noteable.unsubscribe(@sent_notification.recipient) flash[:notice] = "You have been unsubscribed from this thread." + if current_user case noteable when Issue diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 17aed816cbd..5d7ecfeacf4 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -101,7 +101,7 @@ class SessionsController < Devise::SessionsController # Prevent alert from popping up on the first page shown after authentication. flash[:alert] = nil - redirect_to user_omniauth_authorize_path(provider.to_sym) + redirect_to omniauth_authorize_path(:user, provider) end def valid_otp_attempt?(user) diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 2a17c1f34db..dee57e4a388 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -1,8 +1,10 @@ class SnippetsController < ApplicationController - before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] + include ToggleAwardEmoji + + before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :download] # Allow read snippet - before_action :authorize_read_snippet!, only: [:show, :raw] + before_action :authorize_read_snippet!, only: [:show, :raw, :download] # Allow modify snippet before_action :authorize_update_snippet!, only: [:edit, :update] @@ -10,7 +12,7 @@ class SnippetsController < ApplicationController # Allow destroy snippet before_action :authorize_admin_snippet!, only: [:destroy] - skip_before_action :authenticate_user!, only: [:index, :show, :raw] + skip_before_action :authenticate_user!, only: [:index, :show, :raw, :download] layout 'snippets' respond_to :html @@ -73,6 +75,14 @@ class SnippetsController < ApplicationController ) end + def download + send_data( + @snippet.content, + type: 'text/plain; charset=utf-8', + filename: @snippet.sanitized_file_name + ) + end + protected def snippet @@ -85,6 +95,7 @@ class SnippetsController < ApplicationController PersonalSnippet.find(params[:id]) end end + alias_method :awardable, :snippet def authorize_read_snippet! authenticate_user! unless can?(current_user, :read_personal_snippet, @snippet) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a99632454d9..838ecc837e4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -65,7 +65,7 @@ class UsersController < ApplicationController format.html { render 'show' } format.json do render json: { - html: view_to_html_string("snippets/_snippets", collection: @snippets) + html: view_to_html_string("snippets/_snippets", collection: @snippets, remote: true) } end end @@ -73,7 +73,7 @@ class UsersController < ApplicationController def calendar calendar = contributions_calendar - @timestamps = calendar.timestamps + @activity_dates = calendar.activity_dates render 'calendar', layout: false end diff --git a/app/finders/access_requests_finder.rb b/app/finders/access_requests_finder.rb new file mode 100644 index 00000000000..b6ee49df99b --- /dev/null +++ b/app/finders/access_requests_finder.rb @@ -0,0 +1,27 @@ +class AccessRequestsFinder + attr_accessor :source + + # Arguments: + # source - a Group or Project + def initialize(source) + @source = source + end + + def execute(*args) + execute!(*args) + rescue Gitlab::Access::AccessDeniedError + [] + end + + def execute!(current_user) + raise Gitlab::Access::AccessDeniedError unless can_see_access_requests?(current_user) + + source.requesters + end + + private + + def can_see_access_requests?(current_user) + source && Ability.allowed?(current_user, :"admin_#{source.class.to_s.underscore}", source) + end +end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 33daac0399e..9f170428100 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -64,7 +64,7 @@ class IssuableFinder if project? @project = Project.find(params[:project_id]) - unless Ability.abilities.allowed?(current_user, :read_project, @project) + unless Ability.allowed?(current_user, :read_project, @project) @project = nil end else @@ -183,17 +183,12 @@ class IssuableFinder end def by_state(items) - case params[:state] - when 'closed' - items.closed - when 'merged' - items.respond_to?(:merged) ? items.merged : items.closed - when 'all' - items - when 'opened' - items.opened + params[:state] ||= 'all' + + if items.respond_to?(params[:state]) + items.public_send(params[:state]) else - raise 'You must specify default state' + items end end @@ -216,7 +211,14 @@ class IssuableFinder end def by_search(items) - items = items.search(search) if search + if search + items = + if search =~ iid_pattern + items.where(iid: $~[:iid]) + else + items.full_search(search) + end + end items end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index c2befa5a5b3..be00a219205 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -25,4 +25,8 @@ class IssuesFinder < IssuableFinder def init_collection Issue.visible_to_user(current_user) end + + def iid_pattern + @iid_pattern ||= %r{\A#{Regexp.escape(Issue.reference_prefix)}(?<iid>\d+)\z} + end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index b258216d0d4..3b254e7d9d5 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -19,4 +19,14 @@ class MergeRequestsFinder < IssuableFinder def klass MergeRequest end + + private + + def iid_pattern + @iid_pattern ||= %r{\A[ + #{Regexp.escape(MergeRequest.reference_prefix)} + #{Regexp.escape(Issue.reference_prefix)} + ](?<iid>\d+)\z + }x + end end diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb new file mode 100644 index 00000000000..79eb45568be --- /dev/null +++ b/app/finders/move_to_project_finder.rb @@ -0,0 +1,20 @@ +class MoveToProjectFinder + PAGE_SIZE = 50 + + def initialize(user) + @user = user + end + + def execute(from_project, search: nil, offset_id: nil) + projects = @user.projects_where_can_admin_issues + projects = projects.search(search) if search.present? + projects = projects.excluding_project(from_project) + + # infinite scroll using offset + projects = projects.where('projects.id < ?', offset_id) if offset_id.present? + projects = projects.limit(PAGE_SIZE) + + # to ask for Project#name_with_namespace + projects.includes(namespace: :owner) + end +end diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 641fbf838f1..32aea75486d 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -1,30 +1,34 @@ class PipelinesFinder - attr_reader :project + attr_reader :project, :pipelines def initialize(project) @project = project + @pipelines = project.pipelines end - def execute(pipelines, scope) - case scope - when 'running' - pipelines.running_or_pending - when 'branches' - from_ids(pipelines, ids_for_ref(pipelines, branches)) - when 'tags' - from_ids(pipelines, ids_for_ref(pipelines, tags)) - else - pipelines - end + def execute(scope: nil) + scoped_pipelines = + case scope + when 'running' + pipelines.running_or_pending + when 'branches' + from_ids(ids_for_ref(branches)) + when 'tags' + from_ids(ids_for_ref(tags)) + else + pipelines + end + + scoped_pipelines.order(id: :desc) end private - def ids_for_ref(pipelines, refs) + def ids_for_ref(refs) pipelines.where(ref: refs).group(:ref).select('max(id)') end - def from_ids(pipelines, ids) + def from_ids(ids) pipelines.unscoped.where(id: ids) end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 2f0a9659d15..c7911736812 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -1,6 +1,7 @@ class ProjectsFinder < UnionFinder - def execute(current_user = nil, options = {}) + def execute(current_user = nil, project_ids_relation = nil) segments = all_projects(current_user) + segments.map! { |s| s.where(id: project_ids_relation) } if project_ids_relation find_union(segments, Project) end diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb new file mode 100644 index 00000000000..b474f0805dc --- /dev/null +++ b/app/finders/tags_finder.rb @@ -0,0 +1,29 @@ +class TagsFinder + def initialize(repository, params) + @repository = repository + @params = params + end + + def execute + tags = @repository.tags_sorted_by(sort) + filter_by_name(tags) + end + + private + + def sort + @params[:sort].presence + end + + def search + @params[:search].presence + end + + def filter_by_name(tags) + if search + tags.select { |tag| tag.name.include?(search) } + else + tags + end + end +end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index ff866c2faa5..a93a63bdb9b 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -17,7 +17,7 @@ class TodosFinder attr_accessor :current_user, :params - def initialize(current_user, params) + def initialize(current_user, params = {}) @current_user = current_user @params = params end @@ -27,11 +27,13 @@ class TodosFinder items = by_action_id(items) items = by_action(items) items = by_author(items) - items = by_project(items) items = by_state(items) items = by_type(items) + # Filtering by project HAS TO be the last because we use + # the project IDs yielded by the todos query thus far + items = by_project(items) - items.reorder(id: :desc) + sort(items) end private @@ -81,7 +83,7 @@ class TodosFinder if project? @project = Project.find(params[:project_id]) - unless Ability.abilities.allowed?(current_user, :read_project, @project) + unless Ability.allowed?(current_user, :read_project, @project) @project = nil end else @@ -91,14 +93,9 @@ class TodosFinder @project end - def projects - return @projects if defined?(@projects) - - if project? - @projects = project - else - @projects = ProjectsFinder.new.execute(current_user) - end + def projects(items) + item_project_ids = items.reorder(nil).select(:project_id) + ProjectsFinder.new.execute(current_user, item_project_ids) end def type? @@ -109,6 +106,10 @@ class TodosFinder params[:type] end + def sort(items) + params[:sort] ? items.sort(params[:sort]) : items.reorder(id: :desc) + end + def by_action(items) if action? items = items.where(action: to_action_id) @@ -136,8 +137,9 @@ class TodosFinder def by_project(items) if project? items = items.where(project: project) - elsif projects - items = items.merge(projects).joins(:project) + else + item_projects = projects(items) + items = items.merge(item_projects).joins(:project) end items diff --git a/app/finders/trending_projects_finder.rb b/app/finders/trending_projects_finder.rb deleted file mode 100644 index 81a12403801..00000000000 --- a/app/finders/trending_projects_finder.rb +++ /dev/null @@ -1,11 +0,0 @@ -class TrendingProjectsFinder - def execute(current_user, start_date = 1.month.ago) - projects_for(current_user).trending(start_date) - end - - private - - def projects_for(current_user) - ProjectsFinder.new.execute(current_user) - end -end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index e12a1052988..16136d02530 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -16,7 +16,7 @@ module AppearancesHelper end def brand_text - markdown(brand_item.description) + markdown_field(brand_item, :description) end def brand_item @@ -32,6 +32,8 @@ module AppearancesHelper end def custom_icon(icon_name, size: 16) + # We can't simply do the below, because there are some .erb SVGs. + # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe render "shared/icons/#{icon_name}.svg", size: size end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 50de93d4bdf..ebd78bf9888 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -110,7 +110,7 @@ module ApplicationHelper project = event.project # Skip if project repo is empty or MR disabled - return false unless project && !project.empty_repo? && project.merge_requests_enabled + return false unless project && !project.empty_repo? && project.feature_available?(:merge_requests, current_user) # Skip if user already created appropriate MR return false if project.merge_requests.where(source_branch: event.branch_name).opened.any? @@ -163,9 +163,13 @@ module ApplicationHelper # `html_class` argument is provided. # # Returns an HTML-safe String - def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) + def time_ago_with_tooltip(time, placement: 'top', html_class: '', skip_js: false, short_format: false) + css_classes = short_format ? 'js-short-timeago' : 'js-timeago' + css_classes << " #{html_class}" unless html_class.blank? + css_classes << ' js-timeago-pending' unless skip_js + element = content_tag :time, time.to_s, - class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}", + class: css_classes, datetime: time.to_time.getutc.iso8601, title: time.to_time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } @@ -245,7 +249,7 @@ module ApplicationHelper milestone_title: params[:milestone_title], assignee_id: params[:assignee_id], author_id: params[:author_id], - issue_search: params[:issue_search], + search: params[:search], label_name: params[:label_name] } @@ -276,32 +280,6 @@ module ApplicationHelper end end - def state_filters_text_for(entity, project) - titles = { - opened: "Open" - } - - entity_title = titles[entity] || entity.to_s.humanize - - count = - if project.nil? - nil - elsif current_controller?(:issues) - project.issues.visible_to_user(current_user).send(entity).count - elsif current_controller?(:merge_requests) - project.merge_requests.send(entity).count - end - - html = content_tag :span, entity_title - - if count.present? - html += " " - html += content_tag :span, number_with_delimiter(count), class: 'badge' - end - - html.html_safe - end - def truncate_first_line(message, length = 50) truncate(message.each_line.first.chomp, length: length) if message end @@ -316,4 +294,8 @@ module ApplicationHelper capture(&block) end end + + def page_class + "issue-boards-page" if current_controller?(:boards) + end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 78c0b79d2bd..6229384817b 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -11,18 +11,6 @@ module ApplicationSettingsHelper current_application_settings.signin_enabled? end - def extra_sign_in_text - current_application_settings.sign_in_text - end - - def after_sign_up_text - current_application_settings.after_sign_up_text - end - - def shared_runners_text - current_application_settings.shared_runners_text - end - def user_oauth_applications? current_application_settings.user_oauth_applications end @@ -31,6 +19,10 @@ module ApplicationSettingsHelper current_application_settings.akismet_enabled? end + def koding_enabled? + current_application_settings.koding_enabled? + end + def allowed_protocols_present? current_application_settings.enabled_git_access_protocol.present? end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 6ff40c6b461..b7e0ff8ecd0 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -1,23 +1,24 @@ module AvatarsHelper - def author_avatar(commit_or_event, options = {}) user_avatar(options.merge({ user: commit_or_event.author, user_name: commit_or_event.author_name, user_email: commit_or_event.author_email, + css_class: 'hidden-xs' })) end - private - def user_avatar(options = {}) avatar_size = options[:size] || 16 user_name = options[:user].try(:name) || options[:user_name] + css_class = options[:css_class] || '' + avatar = image_tag( avatar_icon(options[:user] || options[:user_email], avatar_size), - class: "avatar has-tooltip hidden-xs s#{avatar_size}", + class: "avatar has-tooltip s#{avatar_size} #{css_class}", alt: "#{user_name}'s avatar", - title: user_name + title: user_name, + data: { container: 'body' } ) if options[:user] @@ -26,5 +27,4 @@ module AvatarsHelper mail_to(options[:user_email], avatar) end end - end diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb new file mode 100644 index 00000000000..aa134cea31c --- /dev/null +++ b/app/helpers/award_emoji_helper.rb @@ -0,0 +1,9 @@ +module AwardEmojiHelper + def toggle_award_url(awardable) + if @project + url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) + else + url_for([:toggle_award_emoji, awardable]) + end + end +end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 48c27828219..e13b7cdd707 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -11,17 +11,14 @@ module BlobHelper def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) return unless current_user - blob = project.repository.blob_at(ref, path) rescue nil + blob = options.delete(:blob) + blob ||= project.repository.blob_at(ref, path) rescue nil return unless blob - from_mr = options[:from_merge_request_id] - link_opts = {} - link_opts[:from_merge_request_id] = from_mr if from_mr - edit_path = namespace_project_edit_blob_path(project.namespace, project, tree_join(ref, path), - link_opts) + options[:link_opts]) if !on_top_of_branch?(project, ref) button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' } @@ -182,17 +179,50 @@ module BlobHelper } end + def selected_template(issuable) + templates = issuable_templates(issuable) + params[:issuable_template] if templates.include?(params[:issuable_template]) + end + + def can_add_template?(issuable) + names = issuable_templates(issuable) + names.empty? && can?(current_user, :push_code, @project) && !@project.private? + end + + def merge_request_template_names + @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project) + end + + def issue_template_names + @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project) + end + + def issuable_templates(issuable) + @issuable_templates ||= + if issuable.is_a?(Issue) + issue_template_names + elsif issuable.is_a?(MergeRequest) + merge_request_template_names + end + end + + def ref_project + @ref_project ||= @target_project || @project + end + def gitignore_names - @gitignore_names ||= - Gitlab::Template::Gitignore.categories.keys.map do |k| - [k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names end def gitlab_ci_ymls - @gitlab_ci_ymls ||= - Gitlab::Template::GitlabCiYml.categories.keys.map do |k| - [k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names + end + + def blob_editor_paths + { + 'relative-url-root' => Rails.application.config.relative_url_root, + 'assets-prefix' => Gitlab::Application.config.assets.prefix, + 'blob-language' => @blob && @blob.language.try(:ace_mode) + } end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb new file mode 100644 index 00000000000..b7247ffa8b2 --- /dev/null +++ b/app/helpers/boards_helper.rb @@ -0,0 +1,12 @@ +module BoardsHelper + def board_data + board = @board || @boards.first + + { + endpoint: namespace_project_boards_path(@project.namespace, @project), + board_id: board.id, + disabled: !can?(current_user, :admin_list, @project), + issue_link_base: namespace_project_issues_path(@project.namespace, @project) + } + end +end diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 43a29c96bca..eb03ced67eb 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -3,7 +3,7 @@ module BroadcastMessagesHelper return unless message.present? content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do - icon('bullhorn') << ' ' << render_broadcast_message(message.message) + icon('bullhorn') << ' ' << render_broadcast_message(message) end end @@ -32,7 +32,7 @@ module BroadcastMessagesHelper end end - def render_broadcast_message(message) - Banzai.render(message, pipeline: :broadcast_message).html_safe + def render_broadcast_message(broadcast_message) + Banzai.render_field(broadcast_message, :message).html_safe end end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index b478580978b..85e1dc33ee8 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -15,13 +15,14 @@ module ButtonHelper # # See http://clipboardjs.com/#usage def clipboard_button(data = {}) + css_class = data[:class] || 'btn-clipboard btn-transparent' data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data) content_tag :button, icon('clipboard'), - class: "btn btn-clipboard", + class: "btn #{css_class}", data: data, type: :button, - title: "Copy to Clipboard" + title: 'Copy to Clipboard' end def http_clone_button(project, placement = 'right', append_link: true) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index ea2f5f9281a..b7f48630bd4 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -25,6 +25,11 @@ module CiStatusHelper end end + def ci_status_for_statuseable(subject) + status = subject.try(:status) || 'not found' + status.humanize + end + def ci_icon_for_status(status) icon_name = case status @@ -38,6 +43,10 @@ module CiStatusHelper 'icon_status_pending' when 'running' 'icon_status_running' + when 'play' + 'icon_play' + when 'created' + 'icon_status_created' else 'icon_status_cancel' end @@ -47,14 +56,14 @@ module CiStatusHelper def render_commit_status(commit, tooltip_placement: 'auto left') project = commit.project - path = builds_namespace_project_commit_path(project.namespace, project, commit) - render_status_with_link('commit', commit.status, path, tooltip_placement) + path = pipelines_namespace_project_commit_path(project.namespace, project, commit) + render_status_with_link('commit', commit.status, path, tooltip_placement: tooltip_placement) end def render_pipeline_status(pipeline, tooltip_placement: 'auto left') project = pipeline.project path = namespace_project_pipeline_path(project.namespace, project, pipeline) - render_status_with_link('pipeline', pipeline.status, path, tooltip_placement) + render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement) end def no_runners_for_project?(project) @@ -62,13 +71,17 @@ module CiStatusHelper Ci::Runner.shared.blank? end - private + def render_status_with_link(type, status, path = nil, tooltip_placement: 'auto left', cssclass: '', container: 'body') + klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}" + title = "#{type.titleize}: #{ci_label_for_status(status)}" + data = { toggle: 'tooltip', placement: tooltip_placement, container: container } - def render_status_with_link(type, status, path, tooltip_placement, cssclass: '') - link_to ci_icon_for_status(status), - path, - class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}", - title: "#{type.titleize}: #{ci_label_for_status(status)}", - data: { toggle: 'tooltip', placement: tooltip_placement } + if path + link_to ci_icon_for_status(status), path, + class: klass, title: title, data: data + else + content_tag :span, ci_icon_for_status(status), + class: klass, title: title, data: data + end end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 7a02d0b10d9..33dcee49aee 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -98,28 +98,31 @@ module CommitsHelper end def link_to_browse_code(project, commit) - if current_controller?(:projects, :commits) - if @repo.blob_at(commit.id, @path) - return link_to( - "Browse File", - namespace_project_blob_path(project.namespace, project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) - elsif @path.present? - return link_to( - "Browse Directory", - namespace_project_tree_path(project.namespace, project, - tree_join(commit.id, @path)), - class: "btn btn-default" - ) - end + if @path.blank? + return link_to( + "Browse Files", + namespace_project_tree_path(project.namespace, project, commit), + class: "btn btn-default" + ) + end + + return unless current_controller?(:projects, :commits) + + if @repo.blob_at(commit.id, @path) + return link_to( + "Browse File", + namespace_project_blob_path(project.namespace, project, + tree_join(commit.id, @path)), + class: "btn btn-default" + ) + elsif @path.present? + return link_to( + "Browse Directory", + namespace_project_tree_path(project.namespace, project, + tree_join(commit.id, @path)), + class: "btn btn-default" + ) end - link_to( - "Browse Files", - namespace_project_tree_path(project.namespace, project, commit), - class: "btn btn-default" - ) end def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true) diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb index f1dc906cab4..aa54ee07bdc 100644 --- a/app/helpers/compare_helper.rb +++ b/app/helpers/compare_helper.rb @@ -3,7 +3,7 @@ module CompareHelper from.present? && to.present? && from != to && - project.merge_requests_enabled && + project.feature_available?(:merge_requests, current_user) && project.repository.branch_names.include?(from) && project.repository.branch_names.include?(to) end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index cc7121b1163..0725c3f4c56 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -13,12 +13,11 @@ module DiffHelper end def diff_view - diff_views = %w(inline parallel) - - if diff_views.include?(cookies[:diff_view]) - cookies[:diff_view] - else - diff_views.first + @diff_view ||= begin + diff_views = %w(inline parallel) + diff_view = cookies[:diff_view] + diff_view = diff_views.first unless diff_views.include?(diff_view) + diff_view.to_sym end end @@ -33,12 +32,23 @@ module DiffHelper options end - def unfold_bottom_class(bottom) - bottom ? 'js-unfold js-unfold-bottom' : '' - end + def diff_match_line(old_pos, new_pos, text: '', view: :inline, bottom: false) + content = content_tag :td, text, class: "line_content match #{view == :inline ? '' : view}" + cls = ['diff-line-num', 'unfold', 'js-unfold'] + cls << 'js-unfold-bottom' if bottom + + html = '' + if old_pos + html << content_tag(:td, '...', class: cls + ['old_line'], data: { linenumber: old_pos }) + html << content unless view == :inline + end + + if new_pos + html << content_tag(:td, '...', class: cls + ['new_line'], data: { linenumber: new_pos }) + html << content + end - def unfold_class(unfold) - unfold ? 'unfold js-unfold' : '' + html.html_safe end def diff_line_content(line, line_type = nil) @@ -67,11 +77,11 @@ module DiffHelper end def inline_diff_btn - diff_btn('Inline', 'inline', diff_view == 'inline') + diff_btn('Inline', 'inline', diff_view == :inline) end def parallel_diff_btn - diff_btn('Side-by-side', 'parallel', diff_view == 'parallel') + diff_btn('Side-by-side', 'parallel', diff_view == :parallel) end def submodule_link(blob, ref, repository = @repository) @@ -99,11 +109,11 @@ module DiffHelper end end - def diff_file_html_data(project, diff_file) - commit = commit_for_diff(diff_file) + def diff_file_html_data(project, diff_file_path, diff_commit_id) { blob_diff_path: namespace_project_blob_diff_path(project.namespace, project, - tree_join(commit.id, diff_file.file_path)) + tree_join(diff_commit_id, diff_file_path)), + view: diff_view } end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 4566f3782cc..81e0b6bb5ae 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -40,8 +40,9 @@ module DropdownsHelper end def dropdown_toggle(toggle_text, data_attr, options = {}) + default_label = data_attr[:default_label] content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do - output = content_tag(:span, toggle_text, class: "dropdown-toggle-text") + output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}") output << icon('chevron-down') output.html_safe end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 337b0aacbb5..2b1f3825adc 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -1,5 +1,5 @@ module ExploreHelper - def filter_projects_path(options={}) + def filter_projects_path(options = {}) exist_opts = { sort: params[:sort], scope: params[:scope], diff --git a/app/helpers/git_helper.rb b/app/helpers/git_helper.rb index 09684955233..8ab394384f3 100644 --- a/app/helpers/git_helper.rb +++ b/app/helpers/git_helper.rb @@ -2,4 +2,8 @@ module GitHelper def strip_gpg_signature(text) text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "") end + + def short_sha(text) + Commit.truncate_sha(text) + end end diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index 1a259656f31..0772d848289 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -13,14 +13,12 @@ module GitlabMarkdownHelper def link_to_gfm(body, url, html_options = {}) return "" if body.blank? - escaped_body = if body.start_with?('<img') - body - else - escape_once(body) - end - - user = current_user if defined?(current_user) - gfm_body = Banzai.render(escaped_body, project: @project, current_user: user, pipeline: :single_line) + context = { + project: @project, + current_user: (current_user if defined?(current_user)), + pipeline: :single_line, + } + gfm_body = Banzai.render(body, context) fragment = Nokogiri::HTML::DocumentFragment.parse(gfm_body) if fragment.children.size == 1 && fragment.children[0].name == 'a' @@ -51,17 +49,15 @@ module GitlabMarkdownHelper context[:project] ||= @project html = Banzai.render(text, context) + banzai_postprocess(html, context) + end - context.merge!( - current_user: (current_user if defined?(current_user)), - - # RelativeLinkFilter - requested_path: @path, - project_wiki: @project_wiki, - ref: @ref - ) + def markdown_field(object, field) + object = object.for_display if object.respond_to?(:for_display) + return "" unless object.present? - Banzai.post_process(html, context) + html = Banzai.render_field(object, field) + banzai_postprocess(html, object.banzai_render_context(field)) end def asciidoc(text) @@ -196,4 +192,18 @@ module GitlabMarkdownHelper icon(options[:icon]) end end + + # Calls Banzai.post_process with some common context options + def banzai_postprocess(html, context) + context.merge!( + current_user: (current_user if defined?(current_user)), + + # RelativeLinkFilter + requested_path: @path, + project_wiki: @project_wiki, + ref: @ref + ) + + Banzai.post_process(html, context) + end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 5386ddadc62..670a7ca36f4 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -46,6 +46,10 @@ module GitlabRoutingHelper namespace_project_environments_path(project.namespace, project, *args) end + def project_cycle_analytics_path(project, *args) + namespace_project_cycle_analytics_path(project.namespace, project, *args) + end + def project_builds_path(project, *args) namespace_project_builds_path(project.namespace, project, *args) end @@ -66,6 +70,10 @@ module GitlabRoutingHelper namespace_project_runner_path(@project.namespace, @project, runner, *args) end + def environment_path(environment, *args) + namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args) + end + def issue_path(entity, *args) namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args) end @@ -98,6 +106,14 @@ module GitlabRoutingHelper end end + def toggle_award_emoji_personal_snippet_path(*args) + toggle_award_emoji_snippet_path(*args) + end + + def toggle_award_emoji_namespace_project_project_snippet_path(*args) + toggle_award_emoji_namespace_project_snippet_path(*args) + end + ## Members def project_members_url(project, *args) namespace_project_project_members_url(project.namespace, project) @@ -149,4 +165,20 @@ module GitlabRoutingHelper def resend_invite_group_member_path(group_member, *args) resend_invite_group_group_member_path(group_member.source, group_member) end + + # Artifacts + + def artifacts_action_path(path, project, build) + action, path_params = path.split('/', 2) + args = [project.namespace, project, build, path_params] + + case action + when 'download' + download_namespace_project_build_artifacts_path(*args) + when 'browse' + browse_namespace_project_build_artifacts_path(*args) + when 'file' + file_namespace_project_build_artifacts_path(*args) + end + end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index b9211e88473..ab880ed6de0 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -23,4 +23,29 @@ module GroupsHelper full_title end end + + def projects_lfs_status(group) + lfs_status = + if group.lfs_enabled? + group.projects.select(&:lfs_enabled?).size + else + group.projects.reject(&:lfs_enabled?).size + end + + size = group.projects.size + + if lfs_status == size + 'for all projects' + else + "for #{lfs_status} out of #{pluralize(size, 'project')}" + end + end + + def group_lfs_status(group) + status = group.lfs_enabled? ? 'enabled' : 'disabled' + + content_tag(:span, class: "lfs-#{status}") do + "#{status.humanize} #{projects_lfs_status(group)}" + end + end end diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 109bc1a02d1..021d2b14718 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -1,4 +1,9 @@ module ImportHelper + def import_project_target(owner, name) + namespace = current_user.can_create_group? ? owner : current_user.namespace_path + "#{namespace}/#{name}" + end + def github_project_link(path_with_namespace) link_to path_with_namespace, github_project_url(path_with_namespace), target: '_blank' end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 47d174361db..692fadd505f 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -8,18 +8,12 @@ module IssuablesHelper end def multi_label_name(current_labels, default_label) - # current_labels may be a string from before - if current_labels.is_a?(Array) - if current_labels.count > 1 - "#{current_labels[0]} +#{current_labels.count - 1} more" + if current_labels && current_labels.any? + title = current_labels.first.try(:title) + if current_labels.size > 1 + "#{title} +#{current_labels.size - 1} more" else - current_labels[0] - end - elsif current_labels.is_a?(String) - if current_labels.nil? || current_labels.empty? - default_label - else - current_labels + title end else default_label @@ -49,6 +43,19 @@ module IssuablesHelper end end + def project_dropdown_label(project_id, default_label) + return default_label if project_id.nil? + return "Any project" if project_id == "0" + + project = Project.find_by(id: project_id) + + if project + project.name_with_namespace + else + default_label + end + end + def milestone_dropdown_label(milestone_title, default_label = "Milestone") if milestone_title == Milestone::Upcoming.name milestone_title = Milestone::Upcoming.title @@ -72,6 +79,33 @@ module IssuablesHelper end end + def issuable_labels_tooltip(labels, limit: 5) + first, last = labels.partition.with_index{ |_, i| i < limit } + + label_names = first.collect(&:name) + label_names << "and #{last.size} more" unless last.empty? + + label_names.join(', ') + end + + def issuables_state_counter_text(issuable_type, state) + titles = { + opened: "Open" + } + + state_title = titles[state] || state.to_s.humanize + + count = + Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do + issuables_count_for_state(issuable_type, state) + end + + html = content_tag(:span, state_title) + html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge') + + html.html_safe + end + private def sidebar_gutter_collapsed? @@ -89,4 +123,22 @@ module IssuablesHelper issuable.open? ? :opened : :closed end end + + def issuables_count_for_state(issuable_type, state) + issuables_finder = public_send("#{issuable_type}_finder") + issuables_finder.params[:state] = state + + issuables_finder.execute.page(1).total_count + end + + IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page] + private_constant :IRRELEVANT_PARAMS_FOR_CACHE_KEY + + def issuables_state_counter_cache_key(issuable_type, state) + opts = params.with_indifferent_access + opts[:state] = state + opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY) + + hexdigest(['issuables_count', issuable_type, opts.sort].flatten.join('-')) + end end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 2e82b44437b..1644c346dd8 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -113,10 +113,17 @@ module IssuesHelper end end - def award_user_list(awards, current_user) - awards.map do |award| - award.user == current_user ? 'me' : award.user.name - end.join(', ') + def award_user_list(awards, current_user, limit: 10) + names = awards.map do |award| + award.user == current_user ? 'You' : award.user.name + end + + current_user_name = names.delete('You') + names = names.insert(0, current_user_name).compact.first(limit) + + names << "#{awards.size - names.size} more." if awards.size > names.size + + names.to_sentence end def award_active_class(awards, current_user) diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 5e9f5837101..b9f3d6c75c2 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -115,8 +115,9 @@ module LabelsHelper end def labels_filter_path - if @project - namespace_project_labels_path(@project.namespace, @project, :json) + project = @target_project || @project + if project + namespace_project_labels_path(project.namespace, project, :json) else dashboard_labels_path(:json) end diff --git a/app/helpers/lfs_helper.rb b/app/helpers/lfs_helper.rb new file mode 100644 index 00000000000..95b60aeab5f --- /dev/null +++ b/app/helpers/lfs_helper.rb @@ -0,0 +1,81 @@ +module LfsHelper + include Gitlab::Routing.url_helpers + + def require_lfs_enabled! + return if Gitlab.config.lfs.enabled + + render( + json: { + message: 'Git LFS is not enabled on this GitLab server, contact your admin.', + documentation_url: help_url, + }, + status: 501 + ) + end + + def lfs_check_access! + return if download_request? && lfs_download_access? + return if upload_request? && lfs_upload_access? + + if project.public? || (user && user.can?(:read_project, project)) + render_lfs_forbidden + else + render_lfs_not_found + end + end + + def lfs_download_access? + return false unless project.lfs_enabled? + + project.public? || ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? + end + + def user_can_download_code? + has_authentication_ability?(:download_code) && can?(user, :download_code, project) + end + + def build_can_download_code? + has_authentication_ability?(:build_download_code) && can?(user, :build_download_code, project) + end + + def lfs_upload_access? + return false unless project.lfs_enabled? + + has_authentication_ability?(:push_code) && can?(user, :push_code, project) + end + + def render_lfs_forbidden + render( + json: { + message: 'Access forbidden. Check your access level.', + documentation_url: help_url, + }, + content_type: "application/vnd.git-lfs+json", + status: 403 + ) + end + + def render_lfs_not_found + render( + json: { + message: 'Not found.', + documentation_url: help_url, + }, + content_type: "application/vnd.git-lfs+json", + status: 404 + ) + end + + def storage_project + @storage_project ||= begin + result = project + + loop do + break unless result.forked? + result = result.forked_from_project + end + + result + end + end +end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index ec106418f2d..877c77050be 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -6,12 +6,6 @@ module MembersHelper "#{action}_#{member.type.underscore}".to_sym end - def default_show_roles(member) - can?(current_user, action_member_permission(:update, member), member) || - can?(current_user, action_member_permission(:destroy, member), member) || - can?(current_user, action_member_permission(:admin, member), member.source) - end - def remove_member_message(member, user: nil) user = current_user if defined?(current_user) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index db6e731c744..249cb44e9d5 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -72,6 +72,19 @@ module MergeRequestsHelper ) end + def mr_assign_issues_link + issues = MergeRequests::AssignIssuesService.new(@project, + current_user, + merge_request: @merge_request, + closes_issues: mr_closes_issues + ).assignable_issues + path = assign_related_issues_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + if issues.present? + pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" + link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post + end + end + def source_branch_with_namespace(merge_request) branch = link_to(merge_request.source_branch, namespace_project_commits_path(merge_request.source_project.namespace, merge_request.source_project, merge_request.source_branch)) @@ -98,6 +111,20 @@ module MergeRequestsHelper end def merge_request_button_visibility(merge_request, closed) - return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) + return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork? + end + + def merge_request_version_path(project, merge_request, merge_request_diff, start_sha = nil) + diffs_namespace_project_merge_request_path( + project.namespace, project, merge_request, + diff_id: merge_request_diff.id, start_sha: start_sha) + end + + def version_index(merge_request_diff) + @merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff) + end + + def different_base?(version1, version2) + version1 && version2 && version1.base_commit_sha != version2.base_commit_sha end end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index b3e6e468ecd..83a2a4ad3ec 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -35,6 +35,30 @@ module MilestonesHelper milestone.issues.with_label(label.title).send(state).size end + # Returns count of milestones for different states + # Uses explicit hash keys as the 'opened' state URL params differs from the db value + # and we need to add the total + def milestone_counts(milestones) + counts = milestones.reorder(nil).group(:state).count + + { + opened: counts['active'] || 0, + closed: counts['closed'] || 0, + all: counts.values.sum || 0 + } + end + + # Show 'active' class if provided GET param matches check + # `or_blank` allows the function to return 'active' when given an empty param + # Could be refactored to be simpler but that may make it harder to read + def milestone_class_for_state(param, check, match_blank_param = false) + if match_blank_param + 'active' if param.blank? || param == check + else + 'active' if param == check + end + end + def milestone_progress_bar(milestone) options = { class: 'progress-bar progress-bar-success', @@ -47,8 +71,9 @@ module MilestonesHelper end def milestones_filter_dropdown_path - if @project - namespace_project_milestones_path(@project.namespace, @project, :json) + project = @target_project || @project + if project + namespace_project_milestones_path(project.namespace, project, :json) else dashboard_milestones_path(:json) end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 94c6b548ecd..e0b8dc1393b 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -1,6 +1,9 @@ module NamespacesHelper - def namespaces_options(selected = :current_user, display_path: false) + def namespaces_options(selected = :current_user, display_path: false, extra_group: nil) groups = current_user.owned_groups + current_user.masters_groups + + groups << extra_group if extra_group && !Group.exists?(name: extra_group.name) + users = [current_user.namespace] data_attr_group = { 'data-options-parent' => 'groups' } diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 3ff8be5e284..df87fac132d 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -1,21 +1,7 @@ module NavHelper - def nav_menu_collapsed? - cookies[:collapsed_nav] == 'true' - end - - def nav_sidebar_class - if nav_menu_collapsed? - "sidebar-collapsed" - else - "sidebar-expanded" - end - end - def page_sidebar_class if pinned_nav? "page-sidebar-expanded page-sidebar-pinned" - else - "page-sidebar-collapsed" end end @@ -24,6 +10,8 @@ module NavHelper current_path?('merge_requests#diffs') || current_path?('merge_requests#commits') || current_path?('merge_requests#builds') || + current_path?('merge_requests#conflicts') || + current_path?('merge_requests#pipelines') || current_path?('issues#show') if cookies[:collapsed_gutter] == 'true' "page-gutter right-sidebar-collapsed" @@ -40,9 +28,7 @@ module NavHelper class_name << " with-horizontal-nav" if defined?(nav) && nav if pinned_nav? - class_name << " header-expanded header-pinned-nav" - else - class_name << " header-collapsed" + class_name << " header-sidebar-expanded header-sidebar-pinned" end class_name diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 26bde2230a9..b0331f36a2f 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -10,6 +10,10 @@ module NotesHelper Ability.can_edit_note?(current_user, note) end + def note_supports_slash_commands?(note) + Notes::SlashCommandsService.supported?(note, current_user) + end + def noteable_json(noteable) { id: noteable.id, @@ -49,7 +53,7 @@ module NotesHelper } if use_legacy_diff_note - discussion_id = LegacyDiffNote.build_discussion_id( + discussion_id = LegacyDiffNote.discussion_id( @comments_target[:noteable_type], @comments_target[:noteable_id] || @comments_target[:commit_id], line_code @@ -60,7 +64,7 @@ module NotesHelper discussion_id: discussion_id ) else - discussion_id = DiffNote.build_discussion_id( + discussion_id = DiffNote.discussion_id( @comments_target[:noteable_type], @comments_target[:noteable_id] || @comments_target[:commit_id], position @@ -81,10 +85,8 @@ module NotesHelper data = discussion.reply_attributes.merge(line_type: line_type) - content_tag(:div, class: "discussion-reply-holder") do - button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', - data: data, title: 'Add a reply' - end + button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', + data: data, title: 'Add a reply' end def preload_max_access_for_authors(notes, project) diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 22387d66451..7d4d049101a 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -92,12 +92,8 @@ module PageLayoutHelper end end - def fluid_layout(enabled = false) - if @fluid_layout.nil? - @fluid_layout = (current_user && current_user.layout == "fluid") || enabled - else - @fluid_layout - end + def fluid_layout + current_user && current_user.layout == "fluid" end def blank_container(enabled = false) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 505545fbabb..e667c9e4e2e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -27,7 +27,7 @@ module ProjectsHelper author_html = "" # Build avatar image tag - author_html << image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: "avatar avatar-inline #{"s#{opts[:size]}" if opts[:size]}", alt: '') if opts[:avatar] + author_html << image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: "avatar avatar-inline #{"s#{opts[:size]}" if opts[:size]} #{opts[:avatar_class] if opts[:avatar_class]}", alt: '') if opts[:avatar] # Build name span tag if opts[:by_username] @@ -61,7 +61,9 @@ module ProjectsHelper project_link = link_to simple_sanitize(project.name), project_path(project), { class: "project-item-select-holder" } if current_user - project_link << icon("chevron-down", class: "dropdown-toggle-caret js-projects-dropdown-toggle", aria: { label: "Toggle switch project dropdown" }, data: { target: ".js-dropdown-menu-projects", toggle: "dropdown" }) + project_link << button_tag(type: 'button', class: "dropdown-toggle-caret js-projects-dropdown-toggle", aria: { label: "Toggle switch project dropdown" }, data: { target: ".js-dropdown-menu-projects", toggle: "dropdown" }) do + icon("chevron-down") + end end full_title = "#{namespace_link} / #{project_link}".html_safe @@ -116,6 +118,30 @@ module ProjectsHelper license.nickname || license.name end + def last_push_event + return unless current_user + + project_ids = [@project.id] + if fork = current_user.fork_of(@project) + project_ids << fork.id + end + + current_user.recent_push(project_ids) + end + + def project_feature_access_select(field) + # Don't show option "everyone with access" if project is private + options = project_feature_options + + if @project.private? + options.delete('Everyone with access') + highest_available_option = options.values.max if @project.project_feature.send(field) == ProjectFeature::ENABLED + end + + options = options_for_select(options, selected: highest_available_option || @project.project_feature.public_send(field)) + content_tag(:select, options, name: "project[project_feature_attributes][#{field}]", id: "project_project_feature_attributes_#{field}", class: "pull-right form-control", data: { field: field }).html_safe + end + private def get_project_nav_tabs(project, current_user) @@ -176,6 +202,18 @@ module ProjectsHelper nav_tabs.flatten end + def project_lfs_status(project) + if project.lfs_enabled? + content_tag(:span, class: 'lfs-enabled') do + 'Enabled' + end + else + content_tag(:span, class: 'lfs-disabled') do + 'Disabled' + end + end + end + def git_user_name if current_user current_user.name @@ -236,6 +274,60 @@ module ProjectsHelper ) end + def add_koding_stack_path(project) + namespace_project_new_blob_path( + project.namespace, + project, + project.default_branch || 'master', + file_name: '.koding.yml', + commit_message: "Add Koding stack script", + content: <<-CONTENT.strip_heredoc + provider: + aws: + access_key: '${var.aws_access_key}' + secret_key: '${var.aws_secret_key}' + resource: + aws_instance: + #{project.path}-vm: + instance_type: t2.nano + user_data: |- + + # Created by GitLab UI for :> + + echo _KD_NOTIFY_@Installing Base packages...@ + + apt-get update -y + apt-get install git -y + + echo _KD_NOTIFY_@Cloning #{project.name}...@ + + export KODING_USER=${var.koding_user_username} + export REPO_URL=#{root_url}${var.koding_queryString_repo}.git + export BRANCH=${var.koding_queryString_branch} + + sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH + + echo _KD_NOTIFY_@#{project.name} cloned.@ + CONTENT + ) + end + + def koding_project_url(project = nil, branch = nil, sha = nil) + if project + import_path = "/Home/Stacks/import" + + repo = project.path_with_namespace + branch ||= project.default_branch + sha ||= project.commit.short_id + + path = "#{import_path}?repo=#{repo}&branch=#{branch}&sha=#{sha}" + + return URI.join(current_application_settings.koding_url, path).to_s + end + + current_application_settings.koding_url + end + def contribution_guide_path(project) if project && contribution_guide = project.repository.contribution_guide namespace_project_blob_path( @@ -297,16 +389,6 @@ module ProjectsHelper namespace_project_new_blob_path(@project.namespace, @project, tree_join(ref), file_name: 'LICENSE') end - def last_push_event - return unless current_user - - if fork = current_user.fork_of(@project) - current_user.recent_push(fork.id) - else - current_user.recent_push(@project.id) - end - end - def readme_cache_key sha = @project.commit.try(:sha) || 'nil' [@project.path_with_namespace, sha, "readme"].join('-') @@ -345,4 +427,12 @@ module ProjectsHelper message.strip.gsub(project.repository_storage_path.chomp('/'), "[REPOS PATH]") end + + def project_feature_options + { + 'Disabled' => ProjectFeature::DISABLED, + 'Only team members' => ProjectFeature::PRIVATE, + 'Everyone with access' => ProjectFeature::ENABLED + } + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index a2bba139c17..aba3a3f9c5d 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -7,8 +7,10 @@ module SearchHelper projects_autocomplete(term) ].flatten + search_pattern = Regexp.new(Regexp.escape(term), "i") + generic_results = project_autocomplete + default_autocomplete + help_autocomplete - generic_results.select! { |result| result[:label] =~ Regexp.new(term, "i") } + generic_results.select! { |result| result[:label] =~ search_pattern } [ resources_results, @@ -28,6 +30,37 @@ module SearchHelper "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\"" end + def parse_search_result(result) + ref = nil + filename = nil + basename = nil + startline = 0 + + result.each_line.each_with_index do |line, index| + if line =~ /^.*:.*:\d+:/ + ref, filename, startline = line.split(':') + startline = startline.to_i - index + extname = Regexp.escape(File.extname(filename)) + basename = filename.sub(/#{extname}$/, '') + break + end + end + + data = "" + + result.each_line do |line| + data << line.sub(ref, '').sub(filename, '').sub(/^:-\d+-/, '').sub(/^::\d+:/, '') + end + + OpenStruct.new( + filename: filename, + basename: basename, + ref: ref, + startline: startline, + data: data + ) + end + private # Autocomplete results for various settings pages @@ -44,7 +77,7 @@ module SearchHelper def help_autocomplete [ { category: "Help", label: "API Help", url: help_page_path("api/README") }, - { category: "Help", label: "Markdown Help", url: help_page_path("markdown/markdown") }, + { category: "Help", label: "Markdown Help", url: help_page_path("user/markdown") }, { category: "Help", label: "Permissions Help", url: help_page_path("user/permissions") }, { category: "Help", label: "Public Access Help", url: help_page_path("public_access/public_access") }, { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks/README") }, @@ -107,7 +140,7 @@ module SearchHelper Sanitize.clean(str) end - def search_filter_path(options={}) + def search_filter_path(options = {}) exist_opts = { search: params[:search], project_id: params[:project_id], @@ -120,8 +153,18 @@ module SearchHelper search_path(options) end - # Sanitize html generated after parsing markdown from issue description or comment - def search_md_sanitize(html) + # Sanitize a HTML field for search display. Most tags are stripped out and the + # maximum length is set to 200 characters. + def search_md_sanitize(object, field) + html = markdown_field(object, field) + html = Truncato.truncate( + html, + count_tags: false, + count_tail: false, + max_length: 200 + ) + + # Truncato's filtered_tags and filtered_attributes are not quite the same sanitize(html, tags: %w(a p ol ul li pre code)) end end diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 5f27e33c6ad..8706876ae4a 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -49,12 +49,10 @@ module SelectsHelper end def select2_tag(id, opts = {}) - css_class = '' - css_class << 'multiselect ' if opts[:multiple] - css_class << (opts[:class] || '') + opts[:class] << ' multiselect' if opts[:multiple] value = opts[:selected] || '' - hidden_field_tag(id, value, class: css_class) + hidden_field_tag(id, value, opts) end private diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb new file mode 100644 index 00000000000..3d255df66a0 --- /dev/null +++ b/app/helpers/sentry_helper.rb @@ -0,0 +1,9 @@ +module SentryHelper + def sentry_enabled? + Gitlab::Sentry.enabled? + end + + def sentry_context + Gitlab::Sentry.context(current_user) + end +end diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 2dd0bf5d71e..3d4abf76419 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -8,7 +8,9 @@ module ServicesHelper when "note" "Event will be triggered when someone adds a comment" when "issue" - "Event will be triggered when an issue is created/updated/merged" + "Event will be triggered when an issue is created/updated/closed" + when "confidential_issue" + "Event will be triggered when a confidential issue is created/updated/closed" when "merge_request" "Event will be triggered when a merge request is created/updated/merged" when "build" @@ -19,7 +21,7 @@ module ServicesHelper end def service_event_field_name(event) - event = event.pluralize if %w[merge_request issue].include?(event) + event = event.pluralize if %w[merge_request issue confidential_issue].include?(event) "#{event}_events" end end diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb new file mode 100644 index 00000000000..d440edc55ba --- /dev/null +++ b/app/helpers/sidekiq_helper.rb @@ -0,0 +1,19 @@ +module SidekiqHelper + SIDEKIQ_PS_REGEXP = /\A + (?<pid>\d+)\s+ + (?<cpu>[\d\.,]+)\s+ + (?<mem>[\d\.,]+)\s+ + (?<state>[DRSTWXZNLsl\+<]+)\s+ + (?<start>.+)\s+ + (?<command>sidekiq.*\])\s+ + \z/x + + def parse_sidekiq_ps(line) + match = line.match(SIDEKIQ_PS_REGEXP) + if match + match[1..6] + else + %w[? ? ? ? ? ?] + end + end +end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 0a5a8eb5aee..7e33a562077 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -1,10 +1,10 @@ module SnippetsHelper - def reliable_snippet_path(snippet) + def reliable_snippet_path(snippet, opts = nil) if snippet.project_id? namespace_project_snippet_path(snippet.project.namespace, - snippet.project, snippet) + snippet.project, snippet, opts) else - snippet_path(snippet) + snippet_path(snippet, opts) end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index e1c0b497550..8b138a8e69f 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -20,13 +20,19 @@ module SortingHelper end def projects_sort_options_hash - { + options = { sort_value_name => sort_title_name, sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, } + + if current_controller?('admin/projects') + options.merge!(sort_value_largest_repo => sort_title_largest_repo) + end + + options end def sort_title_priority diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index fb85544df2d..c0ec1634cdb 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -3,6 +3,16 @@ module TagsHelper "/tags/#{tag}" end + def filter_tags_path(options = {}) + exist_opts = { + search: params[:search], + sort: params[:sort] + } + + options = exist_opts.merge(options) + namespace_project_tags_path(@project.namespace, @project, @id, options) + end + def tag_list(project) html = '' project.tag_list.each do |tag| diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index 790001222f1..271e839692a 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -15,20 +15,9 @@ module TimeHelper "#{from.to_s(:short)} - #{to.to_s(:short)}" end - def duration_in_numbers(finished_at, started_at) - interval = interval_in_seconds(started_at, finished_at) - time_format = interval < 1.hour ? "%M:%S" : "%H:%M:%S" + def duration_in_numbers(duration) + time_format = duration < 1.hour ? "%M:%S" : "%H:%M:%S" - Time.at(interval).utc.strftime(time_format) - end - - private - - def interval_in_seconds(started_at, finished_at = nil) - if started_at && finished_at - finished_at.to_i - started_at.to_i - elsif started_at - Time.now.to_i - started_at.to_i - end + Time.at(duration).utc.strftime(time_format) end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index e3a208f826a..a9db8bb2b82 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -1,10 +1,10 @@ module TodosHelper def todos_pending_count - @todos_pending_count ||= TodosFinder.new(current_user, state: :pending).execute.count + @todos_pending_count ||= current_user.todos_pending_count end def todos_done_count - @todos_done_count ||= TodosFinder.new(current_user, state: :done).execute.count + @todos_done_count ||= current_user.todos_done_count end def todo_action_name(todo) @@ -78,13 +78,11 @@ module TodosHelper end def todo_actions_options - actions = [ - OpenStruct.new(id: '', title: 'Any Action'), - OpenStruct.new(id: Todo::ASSIGNED, title: 'Assigned'), - OpenStruct.new(id: Todo::MENTIONED, title: 'Mentioned') + [ + { id: '', text: 'Any Action' }, + { id: Todo::ASSIGNED, text: 'Assigned' }, + { id: Todo::MENTIONED, text: 'Mentioned' } ] - - options_from_collection_for_select(actions, 'id', 'title', params[:action_id]) end def todo_projects_options @@ -92,22 +90,48 @@ module TodosHelper projects = projects.includes(:namespace) projects = projects.map do |project| - OpenStruct.new(id: project.id, title: project.name_with_namespace) + { id: project.id, text: project.name_with_namespace } end - projects.unshift(OpenStruct.new(id: '', title: 'Any Project')) - - options_from_collection_for_select(projects, 'id', 'title', params[:project_id]) + projects.unshift({ id: '', text: 'Any Project' }).to_json end def todo_types_options - types = [ - OpenStruct.new(title: 'Any Type', name: ''), - OpenStruct.new(title: 'Issue', name: 'Issue'), - OpenStruct.new(title: 'Merge Request', name: 'MergeRequest') + [ + { id: '', text: 'Any Type' }, + { id: 'Issue', text: 'Issue' }, + { id: 'MergeRequest', text: 'Merge Request' } ] + end + + def todo_actions_dropdown_label(selected_action_id, default_action) + selected_action = todo_actions_options.find { |action| action[:id] == selected_action_id.to_i} + selected_action ? selected_action[:text] : default_action + end - options_from_collection_for_select(types, 'name', 'title', params[:type]) + def todo_types_dropdown_label(selected_type, default_type) + selected_type = todo_types_options.find { |type| type[:id] == selected_type && type[:id] != '' } + selected_type ? selected_type[:text] : default_type + end + + def todo_due_date(todo) + return unless todo.target.try(:due_date) + + is_due_today = todo.target.due_date.today? + is_overdue = todo.target.overdue? + css_class = + if is_due_today + 'text-warning' + elsif is_overdue + 'text-danger' + else + '' + end + + html = "· ".html_safe + html << content_tag(:span, class: css_class) do + "Due #{is_due_today ? "today" : todo.target.due_date.to_s(:medium)}" + end end private diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index dbedf417fa5..4a76c679bad 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -4,23 +4,11 @@ module TreeHelper # # contents - A Grit::Tree object for the current tree def render_tree(tree) - # Render Folders before Files/Submodules + # Sort submodules and folders together by name ahead of files folders, files, submodules = tree.trees, tree.blobs, tree.submodules - tree = "" - - # Render folders if we have any - tree << render(partial: 'projects/tree/tree_item', collection: folders, - locals: { type: 'folder' }) if folders.present? - - # Render files if we have any - tree << render(partial: 'projects/tree/blob_item', collection: files, - locals: { type: 'file' }) if files.present? - - # Render submodules if we have any - tree << render(partial: 'projects/tree/submodule_item', - collection: submodules) if submodules.present? - + items = (folders + submodules).sort_by(&:name) + files + tree << render(partial: "projects/tree/tree_row", collection: items) if items.present? tree.html_safe end diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index d887cdadc34..88f374be1e5 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -34,4 +34,8 @@ module WorkhorseHelper headers.store(*Gitlab::Workhorse.send_artifacts_entry(build, entry)) head :ok end + + def set_workhorse_internal_api_content_type + headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE + end end diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb index 8b83bbd93b7..61a574d3dc0 100644 --- a/app/mailers/base_mailer.rb +++ b/app/mailers/base_mailer.rb @@ -9,7 +9,7 @@ class BaseMailer < ActionMailer::Base default reply_to: Proc.new { default_reply_to_address.format } def can? - Ability.abilities.allowed?(current_user, action, subject) + Ability.allowed?(current_user, action, subject) end private diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb index 415f6e12885..f7ed61625f4 100644 --- a/app/mailers/devise_mailer.rb +++ b/app/mailers/devise_mailer.rb @@ -3,4 +3,12 @@ class DeviseMailer < Devise::Mailer default reply_to: Gitlab.config.gitlab.email_reply_to layout 'devise_mailer' + + protected + + def subject_for(key) + subject = super + subject << " | #{Gitlab.config.gitlab.email_subject_suffix}" if Gitlab.config.gitlab.email_subject_suffix.present? + subject + end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 6f54c42146c..d64e48f774b 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -6,6 +6,11 @@ module Emails mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id)) end + def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) + end + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 45311690293..7b617b359ea 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -45,7 +45,7 @@ module Emails @token = token mail(to: member.invite_email, - subject: "Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}") + subject: subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")) end def member_invite_accepted_email(member_source_type, member_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 9dd11d20ea6..ec27ac517db 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -6,6 +6,11 @@ module Emails mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id)) end + def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) + end + def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @@ -42,6 +47,13 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end + def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + + @resolved_by = User.find(resolved_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id)) + end + private def setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 0cc709f68e4..2444702104e 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -92,6 +92,7 @@ class Notify < BaseMailer subject = "" subject << "#{@project.name} | " if @project subject << extra.join(' | ') if extra.present? + subject << " | #{Gitlab.config.gitlab.email_subject_suffix}" if Gitlab.config.gitlab.email_subject_suffix.present? subject end @@ -108,6 +109,12 @@ class Notify < BaseMailer headers["X-GitLab-#{model.class.name}-ID"] = model.id headers['X-GitLab-Reply-Key'] = reply_key + if !@labels_url && @sent_notification && @sent_notification.unsubscribable? + headers['List-Unsubscribe'] = "<#{unsubscribe_sent_notification_url(@sent_notification, force: true)}>" + + @sent_notification_url = unsubscribe_sent_notification_url(@sent_notification) + end + if Gitlab::IncomingEmail.enabled? address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) address.display_name = @project.name_with_namespace diff --git a/app/models/ability.rb b/app/models/ability.rb index d95a2507199..fa8f8bc3a5f 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -1,30 +1,5 @@ class Ability class << self - # rubocop: disable Metrics/CyclomaticComplexity - def allowed(user, subject) - return anonymous_abilities(user, subject) if user.nil? - return [] unless user.is_a?(User) - return [] if user.blocked? - - case subject - when CommitStatus then commit_status_abilities(user, subject) - when Project then project_abilities(user, subject) - when Issue then issue_abilities(user, subject) - when Note then note_abilities(user, subject) - when ProjectSnippet then project_snippet_abilities(user, subject) - when PersonalSnippet then personal_snippet_abilities(user, subject) - when MergeRequest then merge_request_abilities(user, subject) - when Group then group_abilities(user, subject) - when Namespace then namespace_abilities(user, subject) - when GroupMember then group_member_abilities(user, subject) - when ProjectMember then project_member_abilities(user, subject) - when User then user_abilities - when ExternalIssue, Deployment, Environment then project_abilities(user, subject.project) - when Ci::Runner then runner_abilities(user, subject) - else [] - end.concat(global_abilities(user)) - end - # Given a list of users and a project this method returns the users that can # read the given project. def users_that_can_read_project(users, project) @@ -57,347 +32,7 @@ class Ability issues.select { |issue| issue.visible_to_user?(user) } end - # List of possible abilities for anonymous user - def anonymous_abilities(user, subject) - if subject.is_a?(PersonalSnippet) - anonymous_personal_snippet_abilities(subject) - elsif subject.is_a?(ProjectSnippet) - anonymous_project_snippet_abilities(subject) - elsif subject.is_a?(CommitStatus) - anonymous_commit_status_abilities(subject) - elsif subject.is_a?(Project) || subject.respond_to?(:project) - anonymous_project_abilities(subject) - elsif subject.is_a?(Group) || subject.respond_to?(:group) - anonymous_group_abilities(subject) - elsif subject.is_a?(User) - anonymous_user_abilities - else - [] - end - end - - def anonymous_project_abilities(subject) - project = if subject.is_a?(Project) - subject - else - subject.project - end - - if project && project.public? - rules = [ - :read_project, - :read_wiki, - :read_label, - :read_milestone, - :read_project_snippet, - :read_project_member, - :read_merge_request, - :read_note, - :read_pipeline, - :read_commit_status, - :read_container_image, - :download_code - ] - - # Allow to read builds by anonymous user if guests are allowed - rules << :read_build if project.public_builds? - - # Allow to read issues by anonymous user if issue is not confidential - rules << :read_issue unless subject.is_a?(Issue) && subject.confidential? - - rules - project_disabled_features_rules(project) - else - [] - end - end - - def anonymous_commit_status_abilities(subject) - rules = anonymous_project_abilities(subject.project) - # If subject is Ci::Build which inherits from CommitStatus filter the abilities - rules = filter_build_abilities(rules) if subject.is_a?(Ci::Build) - rules - end - - def anonymous_group_abilities(subject) - rules = [] - - group = if subject.is_a?(Group) - subject - else - subject.group - end - - rules << :read_group if group.public? - - rules - end - - def anonymous_personal_snippet_abilities(snippet) - if snippet.public? - [:read_personal_snippet] - else - [] - end - end - - def anonymous_project_snippet_abilities(snippet) - if snippet.public? - [:read_project_snippet] - else - [] - end - end - - def anonymous_user_abilities - [:read_user] unless restricted_public_level? - end - - def global_abilities(user) - rules = [] - rules << :create_group if user.can_create_group - rules << :read_users_list - rules - end - - def project_abilities(user, project) - rules = [] - key = "/user/#{user.id}/project/#{project.id}" - - RequestStore.store[key] ||= begin - # Push abilities on the users team role - rules.push(*project_team_rules(project.team, user)) - - owner = user.admin? || - project.owner == user || - (project.group && project.group.has_owner?(user)) - - if owner - rules.push(*project_owner_rules) - end - - if project.public? || (project.internal? && !user.external?) - rules.push(*public_project_rules) - - # Allow to read builds for internal projects - rules << :read_build if project.public_builds? - - unless owner || project.team.member?(user) || project_group_member?(project, user) - rules << :request_access if project.request_access_enabled - end - end - - if project.archived? - rules -= project_archived_rules - end - - rules - project_disabled_features_rules(project) - end - end - - def project_team_rules(team, user) - # Rules based on role in project - if team.master?(user) - project_master_rules - elsif team.developer?(user) - project_dev_rules - elsif team.reporter?(user) - project_report_rules - elsif team.guest?(user) - project_guest_rules - else - [] - end - end - - def public_project_rules - @public_project_rules ||= project_guest_rules + [ - :download_code, - :fork_project, - :read_commit_status, - :read_pipeline, - :read_container_image - ] - end - - def project_guest_rules - @project_guest_rules ||= [ - :read_project, - :read_wiki, - :read_issue, - :read_label, - :read_milestone, - :read_project_snippet, - :read_project_member, - :read_merge_request, - :read_note, - :create_project, - :create_issue, - :create_note, - :upload_file - ] - end - - def project_report_rules - @project_report_rules ||= project_guest_rules + [ - :download_code, - :fork_project, - :create_project_snippet, - :update_issue, - :admin_issue, - :admin_label, - :read_commit_status, - :read_build, - :read_container_image, - :read_pipeline, - :read_environment, - :read_deployment - ] - end - - def project_dev_rules - @project_dev_rules ||= project_report_rules + [ - :admin_merge_request, - :update_merge_request, - :create_commit_status, - :update_commit_status, - :create_build, - :update_build, - :create_pipeline, - :update_pipeline, - :create_merge_request, - :create_wiki, - :push_code, - :create_container_image, - :update_container_image, - :create_environment, - :create_deployment - ] - end - - def project_archived_rules - @project_archived_rules ||= [ - :create_merge_request, - :push_code, - :push_code_to_protected_branches, - :update_merge_request, - :admin_merge_request - ] - end - - def project_master_rules - @project_master_rules ||= project_dev_rules + [ - :push_code_to_protected_branches, - :update_project_snippet, - :update_environment, - :update_deployment, - :admin_milestone, - :admin_project_snippet, - :admin_project_member, - :admin_merge_request, - :admin_note, - :admin_wiki, - :admin_project, - :admin_commit_status, - :admin_build, - :admin_container_image, - :admin_pipeline, - :admin_environment, - :admin_deployment - ] - end - - def project_owner_rules - @project_owner_rules ||= project_master_rules + [ - :change_namespace, - :change_visibility_level, - :rename_project, - :remove_project, - :archive_project, - :remove_fork_project, - :destroy_merge_request, - :destroy_issue - ] - end - - def project_disabled_features_rules(project) - rules = [] - - unless project.issues_enabled - rules += named_abilities('issue') - end - - unless project.merge_requests_enabled - rules += named_abilities('merge_request') - end - - unless project.issues_enabled or project.merge_requests_enabled - rules += named_abilities('label') - rules += named_abilities('milestone') - end - - unless project.snippets_enabled - rules += named_abilities('project_snippet') - end - - unless project.wiki_enabled - rules += named_abilities('wiki') - end - - unless project.builds_enabled - rules += named_abilities('build') - rules += named_abilities('pipeline') - rules += named_abilities('environment') - rules += named_abilities('deployment') - end - - unless project.container_registry_enabled - rules += named_abilities('container_image') - end - - rules - end - - def group_abilities(user, group) - rules = [] - rules << :read_group if can_read_group?(user, group) - - owner = user.admin? || group.has_owner?(user) - master = owner || group.has_master?(user) - - # Only group masters and group owners can create new projects - if master - rules += [ - :create_projects, - :admin_milestones - ] - end - - # Only group owner and administrators can admin group - if owner - rules += [ - :admin_group, - :admin_namespace, - :admin_group_member, - :change_visibility_level - ] - end - - if group.public? || (group.internal? && !user.external?) - rules << :request_access if group.request_access_enabled && group.users.exclude?(user) - end - - rules.flatten - end - - def can_read_group?(user, group) - return true if user.admin? - return true if group.public? - return true if group.internal? && !user.external? - return true if group.users.include?(user) - - GroupProjectsFinder.new(group).execute(user).any? - end - + # TODO: make this private and use the actual abilities stuff for this def can_edit_note?(user, note) return false if !note.editable? || !user.present? return true if note.author == user || user.admin? @@ -410,202 +45,23 @@ class Ability end end - def namespace_abilities(user, namespace) - rules = [] - - # Only namespace owner and administrators can admin it - if namespace.owner == user || user.admin? - rules += [ - :create_projects, - :admin_namespace - ] - end - - rules.flatten + def allowed?(user, action, subject) + allowed(user, subject).include?(action) end - [:issue, :merge_request].each do |name| - define_method "#{name}_abilities" do |user, subject| - rules = [] - - if subject.author == user || (subject.respond_to?(:assignee) && subject.assignee == user) - rules += [ - :"read_#{name}", - :"update_#{name}", - ] - end - - rules += project_abilities(user, subject.project) - rules = filter_confidential_issues_abilities(user, subject, rules) if subject.is_a?(Issue) - rules - end - end - - def note_abilities(user, note) - rules = [] - - if note.author == user - rules += [ - :read_note, - :update_note, - :admin_note - ] - end - - if note.respond_to?(:project) && note.project - rules += project_abilities(user, note.project) - end - - rules - end - - def personal_snippet_abilities(user, snippet) - rules = [] - - if snippet.author == user - rules += [ - :read_personal_snippet, - :update_personal_snippet, - :admin_personal_snippet - ] - end - - if snippet.public? || (snippet.internal? && !user.external?) - rules << :read_personal_snippet - end - - rules - end - - def project_snippet_abilities(user, snippet) - rules = [] - - if snippet.author == user || user.admin? - rules += [ - :read_project_snippet, - :update_project_snippet, - :admin_project_snippet - ] - end - - if snippet.public? || (snippet.internal? && !user.external?) || (snippet.private? && snippet.project.team.member?(user)) - rules << :read_project_snippet - end - - rules - end - - def group_member_abilities(user, subject) - rules = [] - target_user = subject.user - group = subject.group - - unless group.last_owner?(target_user) - can_manage = group_abilities(user, group).include?(:admin_group_member) - - if can_manage - rules << :update_group_member - rules << :destroy_group_member - elsif user == target_user - rules << :destroy_group_member - end - end - - rules - end - - def project_member_abilities(user, subject) - rules = [] - target_user = subject.user - project = subject.project - - unless target_user == project.owner - can_manage = project_abilities(user, project).include?(:admin_project_member) - - if can_manage - rules << :update_project_member - rules << :destroy_project_member - elsif user == target_user - rules << :destroy_project_member - end - end - - rules - end - - def commit_status_abilities(user, subject) - rules = project_abilities(user, subject.project) - # If subject is Ci::Build which inherits from CommitStatus filter the abilities - rules = filter_build_abilities(rules) if subject.is_a?(Ci::Build) - rules - end - - def filter_build_abilities(rules) - # If we can't read build we should also not have that - # ability when looking at this in context of commit_status - %w(read create update admin).each do |rule| - rules.delete(:"#{rule}_commit_status") unless rules.include?(:"#{rule}_build") - end - rules - end - - def runner_abilities(user, runner) - if user.is_admin? - [:assign_runner] - elsif runner.is_shared? || runner.locked? - [] - elsif user.ci_authorized_runners.include?(runner) - [:assign_runner] - else - [] - end - end - - def user_abilities - [:read_user] - end + def allowed(user, subject) + return uncached_allowed(user, subject) unless RequestStore.active? - def abilities - @abilities ||= begin - abilities = Six.new - abilities << self - abilities - end + user_key = user ? user.id : 'anonymous' + subject_key = subject ? "#{subject.class.name}/#{subject.id}" : 'global' + key = "/ability/#{user_key}/#{subject_key}" + RequestStore[key] ||= uncached_allowed(user, subject).freeze end private - def restricted_public_level? - current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) - end - - def named_abilities(name) - [ - :"read_#{name}", - :"create_#{name}", - :"update_#{name}", - :"admin_#{name}" - ] - end - - def filter_confidential_issues_abilities(user, issue, rules) - return rules if user.admin? || !issue.confidential? - - unless issue.author == user || issue.assignee == user || issue.project.team.member?(user, Gitlab::Access::REPORTER) - rules.delete(:admin_issue) - rules.delete(:read_issue) - rules.delete(:update_issue) - end - - rules - end - - def project_group_member?(project, user) - project.group && - ( - project.group.members.exists?(user_id: user.id) || - project.group.requesters.exists?(user_id: user.id) - ) + def uncached_allowed(user, subject) + BasePolicy.class_for(subject).abilities(user, subject) end end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index b01a244032d..2340453831e 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -1,4 +1,8 @@ class AbuseReport < ActiveRecord::Base + include CacheMarkdownField + + cache_markdown_field :message, pipeline: :single_line + belongs_to :reporter, class_name: 'User' belongs_to :user @@ -7,6 +11,9 @@ class AbuseReport < ActiveRecord::Base validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } + # For CacheMarkdownField + alias_method :author, :reporter + def remove_user(deleted_by:) user.block DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true) diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 4cf8dd9a8ce..e4106e1c2e9 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -1,4 +1,8 @@ class Appearance < ActiveRecord::Base + include CacheMarkdownField + + cache_markdown_field :description + validates :title, presence: true validates :description, presence: true validates :logo, file_size: { maximum: 1.megabyte } diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 8c19d9dc9c8..c99aa7772bb 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -1,5 +1,7 @@ class ApplicationSetting < ActiveRecord::Base + include CacheMarkdownField include TokenAuthenticatable + add_authentication_token_field :runners_registration_token add_authentication_token_field :health_check_access_token @@ -17,6 +19,11 @@ class ApplicationSetting < ActiveRecord::Base serialize :domain_whitelist, Array serialize :domain_blacklist, Array + cache_markdown_field :sign_in_text + cache_markdown_field :help_page_text + cache_markdown_field :shared_runners_text, pipeline: :plain_markdown + cache_markdown_field :after_sign_up_text + attr_accessor :domain_whitelist_raw, :domain_blacklist_raw validates :session_expire_delay, @@ -55,6 +62,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :akismet_enabled + validates :koding_url, + presence: true, + if: :koding_enabled + validates :max_attachment_size, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -142,13 +153,15 @@ class ApplicationSetting < ActiveRecord::Base default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], domain_whitelist: Settings.gitlab['domain_whitelist'], - import_sources: %w[github bitbucket gitlab gitorious google_code fogbugz git gitlab_project], + import_sources: Gitlab::ImportSources.values, shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], max_artifacts_size: Settings.artifacts['max_size'], require_two_factor_authentication: false, two_factor_grace_period: 48, recaptcha_enabled: false, akismet_enabled: false, + koding_enabled: false, + koding_url: nil, repository_checks_enabled: true, disabled_oauth_sign_in_sources: [], send_user_confirmation_email: false, diff --git a/app/models/blob.rb b/app/models/blob.rb index 0df2805e448..ab92e820335 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,6 +3,9 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour + # The maximum size of an SVG that can be displayed. + MAXIMUM_SVG_SIZE = 2.megabytes + # Wrap a Gitlab::Git::Blob object, or return nil when given nil # # This method prevents the decorated object from evaluating to "truthy" when @@ -19,6 +22,18 @@ class Blob < SimpleDelegator new(blob) end + # Returns the data of the blob. + # + # If the blob is a text based blob the content is converted to UTF-8 and any + # invalid byte sequences are replaced. + def data + if binary? + super + else + @data ||= super.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + end + def no_highlighting? size && size > 1.megabyte end @@ -31,6 +46,10 @@ class Blob < SimpleDelegator text? && language && language.name == 'SVG' end + def size_within_svg_limits? + size <= MAXIMUM_SVG_SIZE + end + def video? UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) end diff --git a/app/models/board.rb b/app/models/board.rb new file mode 100644 index 00000000000..c56422914a9 --- /dev/null +++ b/app/models/board.rb @@ -0,0 +1,15 @@ +class Board < ActiveRecord::Base + belongs_to :project + + has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all + + validates :project, presence: true + + def backlog_list + lists.merge(List.backlog).take + end + + def done_list + lists.merge(List.done).take + end +end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 61498140f27..cb40f33932a 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -1,6 +1,9 @@ class BroadcastMessage < ActiveRecord::Base + include CacheMarkdownField include Sortable + cache_markdown_field :message, pipeline: :broadcast_message + validates :message, presence: true validates :starts_at, presence: true validates :ends_at, presence: true diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 08f396210c9..5dbf66173de 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -1,5 +1,7 @@ module Ci class Build < CommitStatus + include TokenAuthenticatable + belongs_to :runner, class_name: 'Ci::Runner' belongs_to :trigger_request, class_name: 'Ci::TriggerRequest' belongs_to :erased_by, class_name: 'User' @@ -16,14 +18,17 @@ module Ci scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } - scope :manual_actions, ->() { where(when: :manual) } + scope :manual_actions, ->() { where(when: :manual).relevant } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader acts_as_taggable + add_authentication_token_field :token + before_save :update_artifacts_size, if: :artifacts_file_changed? + before_save :ensure_token before_destroy { project } after_create :execute_hooks @@ -38,44 +43,41 @@ module Ci new_build.status = 'pending' new_build.runner_id = nil new_build.trigger_request_id = nil + new_build.token = nil new_build.save end def retry(build, user = nil) - new_build = Ci::Build.new(status: 'pending') - new_build.ref = build.ref - new_build.tag = build.tag - new_build.options = build.options - new_build.commands = build.commands - new_build.tag_list = build.tag_list - new_build.project = build.project - new_build.pipeline = build.pipeline - new_build.name = build.name - new_build.allow_failure = build.allow_failure - new_build.stage = build.stage - new_build.stage_idx = build.stage_idx - new_build.trigger_request = build.trigger_request - new_build.yaml_variables = build.yaml_variables - new_build.when = build.when - new_build.user = user - new_build.environment = build.environment - new_build.save + new_build = Ci::Build.create( + ref: build.ref, + tag: build.tag, + options: build.options, + commands: build.commands, + tag_list: build.tag_list, + project: build.project, + pipeline: build.pipeline, + name: build.name, + allow_failure: build.allow_failure, + stage: build.stage, + stage_idx: build.stage_idx, + trigger_request: build.trigger_request, + yaml_variables: build.yaml_variables, + when: build.when, + user: user, + environment: build.environment, + status_event: 'enqueue' + ) MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) + build.pipeline.mark_as_processable_after_stage(build.stage_idx) new_build end end - state_machine :status, initial: :pending do + state_machine :status do after_transition pending: :running do |build| build.execute_hooks end - # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed - around_transition any => [:success, :failed, :canceled] do |build, block| - block.call - build.pipeline.create_next_builds(build) if build.pipeline - end - after_transition any => [:success, :failed, :canceled] do |build| build.update_coverage build.execute_hooks @@ -83,11 +85,14 @@ module Ci after_transition any => [:success] do |build| if build.environment.present? - service = CreateDeploymentService.new(build.project, build.user, - environment: build.environment, - sha: build.sha, - ref: build.ref, - tag: build.tag) + service = CreateDeploymentService.new( + build.project, build.user, + environment: build.environment, + sha: build.sha, + ref: build.ref, + tag: build.tag, + options: build.options.to_h[:environment], + variables: build.variables) service.execute(build) end end @@ -102,12 +107,12 @@ module Ci end def playable? - project.builds_enabled? && commands.present? && manual? + project.builds_enabled? && commands.present? && manual? && skipped? end def play(current_user = nil) # Try to queue a current build - if self.queue + if self.enqueue self.update(user: current_user) self else @@ -152,6 +157,7 @@ module Ci variables += runner.predefined_variables if runner variables += project.container_registry_variables variables += yaml_variables + variables += user_variables variables += project.secret_variables variables += trigger_request.user_variables if trigger_request variables @@ -176,7 +182,7 @@ module Ci end def repo_url - auth = "gitlab-ci-token:#{token}@" + auth = "gitlab-ci-token:#{ensure_token!}@" project.http_url_to_repo.sub(/^https?:\/\//) do |prefix| prefix + auth end @@ -212,29 +218,33 @@ module Ci end end + def has_trace_file? + File.exist?(path_to_trace) || has_old_trace_file? + end + def has_trace? raw_trace.present? end def raw_trace - if File.file?(path_to_trace) - File.read(path_to_trace) - elsif project.ci_id && File.file?(old_path_to_trace) - # Temporary fix for build trace data integrity - File.read(old_path_to_trace) + if File.exist?(trace_file_path) + File.read(trace_file_path) else # backward compatibility read_attribute :trace end end + ## + # Deprecated + # + # This is a hotfix for CI build data integrity, see #4246 + def has_old_trace_file? + project.ci_id && File.exist?(old_path_to_trace) + end + def trace - trace = raw_trace - if project && trace.present? && project.runners_token.present? - trace.gsub(project.runners_token, 'xxxxxx') - else - trace - end + hide_secrets(raw_trace) end def trace_length @@ -247,6 +257,7 @@ module Ci def trace=(trace) recreate_trace_dir + trace = hide_secrets(trace) File.write(path_to_trace, trace) end @@ -260,12 +271,22 @@ module Ci def append_trace(trace_part, offset) recreate_trace_dir + trace_part = hide_secrets(trace_part) + File.truncate(path_to_trace, offset) if File.exist?(path_to_trace) File.open(path_to_trace, 'ab') do |f| f.write(trace_part) end end + def trace_file_path + if has_old_trace_file? + old_path_to_trace + else + path_to_trace + end + end + def dir_to_trace File.join( Settings.gitlab_ci.builds_path, @@ -327,12 +348,8 @@ module Ci ) end - def token - project.runners_token - end - def valid_token?(token) - project.valid_runners_token?(token) + self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) end def has_tags? @@ -349,7 +366,7 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::BuildDataBuilder.build(self) + build_data = Gitlab::DataBuilder::Build.build(self) project.execute_hooks(build_data.dup, :build_hooks) project.execute_services(build_data.dup, :build_hooks) project.running_or_pending_build_count(force: true) @@ -421,6 +438,15 @@ module Ci read_attribute(:yaml_variables) || build_attributes_from_config[:yaml_variables] || [] end + def user_variables + return [] if user.blank? + + [ + { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, + { key: 'GITLAB_USER_EMAIL', value: user.email, public: true } + ] + end + private def update_artifacts_size @@ -456,13 +482,23 @@ module Ci ] variables << { key: 'CI_BUILD_TAG', value: ref, public: true } if tag? variables << { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } if trigger_request + variables << { key: 'CI_BUILD_MANUAL', value: 'true', public: true } if manual? variables end def build_attributes_from_config return {} unless pipeline.config_processor - + pipeline.config_processor.build_attributes(name) end + + def hide_secrets(trace) + return unless trace + + trace = trace.dup + Ci::MaskSecret.mask!(trace, project.runners_token) if project + Ci::MaskSecret.mask!(trace, token) + trace + end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index bce6a992af6..957f6755b2e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -1,7 +1,9 @@ module Ci class Pipeline < ActiveRecord::Base extend Ci::Model - include Statuseable + include HasStatus + include Importable + include AfterCommitQueue self.table_name = 'ci_commits' @@ -12,17 +14,75 @@ module Ci has_many :builds, class_name: 'Ci::Build', foreign_key: :commit_id has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest', foreign_key: :commit_id - validates_presence_of :sha - validates_presence_of :status - validate :valid_commit_sha + validates_presence_of :sha, unless: :importing? + validates_presence_of :ref, unless: :importing? + validates_presence_of :status, unless: :importing? + validate :valid_commit_sha, unless: :importing? - # Invalidate object and save if when touched - after_touch :update_state - after_save :keep_around_commits + after_save :keep_around_commits, unless: :importing? + + delegate :stages, to: :statuses + + state_machine :status, initial: :created do + event :enqueue do + transition created: :pending + transition [:success, :failed, :canceled, :skipped] => :running + end + + event :run do + transition any => :running + end + + event :skip do + transition any => :skipped + end + + event :drop do + transition any => :failed + end + + event :succeed do + transition any => :success + end + + event :cancel do + transition any => :canceled + end + + before_transition [:created, :pending] => :running do |pipeline| + pipeline.started_at = Time.now + end + + before_transition any => [:success, :failed, :canceled] do |pipeline| + pipeline.finished_at = Time.now + end + + before_transition do |pipeline| + pipeline.update_duration + end + + after_transition [:created, :pending] => :running do |pipeline| + MergeRequest::Metrics.where(merge_request_id: pipeline.merge_requests.map(&:id)). + update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil) + end + + after_transition any => [:success] do |pipeline| + MergeRequest::Metrics.where(merge_request_id: pipeline.merge_requests.map(&:id)). + update_all(latest_build_finished_at: pipeline.finished_at) + end + + after_transition [:created, :pending, :running] => :success do |pipeline| + pipeline.run_after_commit { PipelineSuccessWorker.perform_async(id) } + end + + after_transition do |pipeline, transition| + pipeline.execute_hooks unless transition.loopback? + end + end # ref can't be HEAD or SHA, can only be branch/tag name - scope :latest_successful_for, ->(ref = default_branch) do - where(ref: ref).success.order(id: :desc).limit(1) + def self.latest_successful_for(ref) + where(ref: ref).order(id: :desc).success.first end def self.truncate_sha(sha) @@ -34,6 +94,14 @@ module Ci CommitStatus.where(pipeline: pluck(:id)).stages end + def self.total_duration + where.not(duration: nil).sum(:duration) + end + + def stages_with_latest_statuses + statuses.latest.includes(project: :namespace).order(:stage_idx).group_by(&:stage) + end + def project_id project.id end @@ -98,6 +166,10 @@ module Ci end end + def mark_as_processable_after_stage(stage_idx) + builds.skipped.where('stage_idx > ?', stage_idx).find_each(&:process) + end + def latest? return false unless ref commit = project.commit(ref) @@ -109,37 +181,6 @@ module Ci trigger_requests.any? end - def create_builds(user, trigger_request = nil) - ## - # We persist pipeline only if there are builds available - # - return unless config_processor - - build_builds_for_stages(config_processor.stages, user, - 'success', trigger_request) && save - end - - def create_next_builds(build) - return unless config_processor - - # don't create other builds if this one is retried - latest_builds = builds.latest - return unless latest_builds.exists?(build.id) - - # get list of stages after this build - next_stages = config_processor.stages.drop_while { |stage| stage != build.stage } - next_stages.delete(build.stage) - - # get status for all prior builds - prior_builds = latest_builds.where.not(stage: next_stages) - prior_status = prior_builds.status - - # build builds for next stage that has builds available - # and save pipeline if we have builds - build_builds_for_stages(next_stages, build.user, prior_status, - build.trigger_request) && save - end - def retried @retried ||= (statuses.order(id: :desc) - statuses.latest) end @@ -151,8 +192,16 @@ module Ci end end + def config_builds_attributes + return [] unless config_processor + + config_processor. + builds_for_ref(ref, tag?, trigger_requests.first). + sort_by { |build| build[:stage_idx] } + end + def has_warnings? - builds.latest.ignored.any? + builds.latest.failed_but_allowed.any? end def config_processor @@ -182,10 +231,6 @@ module Ci end end - def skip_ci? - git_commit_message =~ /\[(ci skip|skip ci)\]/i if git_commit_message - end - def environments builds.where.not(environment: nil).success.pluck(:environment).uniq end @@ -207,37 +252,66 @@ module Ci Note.for_commit_id(sha) end + def process! + Ci::ProcessPipelineService.new(project, user).execute(self) + end + + def update_status + with_lock do + case latest_builds_status + when 'pending' then enqueue + when 'running' then run + when 'success' then succeed + when 'failed' then drop + when 'canceled' then cancel + when 'skipped' then skip + end + end + end + def predefined_variables [ { key: 'CI_PIPELINE_ID', value: id.to_s, public: true } ] end + def queued_duration + return unless started_at + + seconds = (started_at - created_at).to_i + seconds unless seconds.zero? + end + + def update_duration + return unless started_at + + self.duration = Gitlab::Ci::PipelineDuration.from_pipeline(self) + end + + def execute_hooks + data = pipeline_data + project.execute_hooks(data, :pipeline_hooks) + project.execute_services(data, :pipeline_hooks) + end + + # Merge requests for which the current pipeline is running against + # the merge request's latest commit. + def merge_requests + @merge_requests ||= project.merge_requests + .where(source_branch: self.ref) + .select { |merge_request| merge_request.pipeline.try(:id) == self.id } + end + private - def build_builds_for_stages(stages, user, status, trigger_request) - ## - # Note that `Array#any?` implements a short circuit evaluation, so we - # build builds only for the first stage that has builds available. - # - stages.any? do |stage| - CreateBuildsService.new(self). - execute(stage, user, status, trigger_request). - any?(&:active?) - end - end - - def update_state - statuses.reload - self.status = if yaml_errors.blank? - statuses.latest.status || 'skipped' - else - 'failed' - end - self.started_at = statuses.started_at - self.finished_at = statuses.finished_at - self.duration = statuses.latest.duration - save + def pipeline_data + Gitlab::DataBuilder::Pipeline.build(self) + end + + def latest_builds_status + return 'failed' unless yaml_errors.blank? + + statuses.latest.status || 'skipped' end def keep_around_commits diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 49f05f881a2..44cb19ece3b 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -2,7 +2,7 @@ module Ci class Runner < ActiveRecord::Base extend Ci::Model - LAST_CONTACT_TIME = 5.minutes.ago + LAST_CONTACT_TIME = 1.hour.ago AVAILABLE_SCOPES = %w[specific shared active paused online] FORM_EDITABLE = %i[description tag_list active run_untagged locked] diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index c9c47ec7419..6959223aed9 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -1,7 +1,7 @@ module Ci class Variable < ActiveRecord::Base extend Ci::Model - + belongs_to :project, class_name: '::Project', foreign_key: :gl_project_id validates_uniqueness_of :key, scope: :gl_project_id @@ -11,7 +11,9 @@ module Ci format: { with: /\A[a-zA-Z0-9_]+\z/, message: "can contain only letters, digits and '_'." } - attr_encrypted :value, + scope :order_key_asc, -> { reorder(key: :asc) } + + attr_encrypted :value, mode: :per_attribute_iv_and_salt, insecure_mode: true, key: Gitlab::Application.secrets.db_key_base, diff --git a/app/models/commit.rb b/app/models/commit.rb index cc413448ce8..e64fd1e0c1b 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -108,15 +108,6 @@ class Commit @diff_line_count end - # Returns a string describing the commit for use in a link title - # - # Example - # - # "Commit: Alex Denisov - Project git clone panel" - def link_title - "Commit: #{author_name} - #{title}" - end - # Returns the commits title. # # Usually, the commit title is the first line of the commit message. @@ -229,7 +220,7 @@ class Commit def diff_refs Gitlab::Diff::DiffRefs.new( - base_sha: self.parent_id || self.sha, + base_sha: self.parent_id || Gitlab::Git::BLANK_SHA, head_sha: self.sha ) end diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 630ee9601e0..ac2477fd973 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -4,12 +4,10 @@ # # range = CommitRange.new('f3f85602...e86e1013', project) # range.exclude_start? # => false -# range.reference_title # => "Commits f3f85602 through e86e1013" # range.to_s # => "f3f85602...e86e1013" # # range = CommitRange.new('f3f856029bc5f966c5a7ee24cf7efefdd20e6019..e86e1013709735be5bb767e2b228930c543f25ae', project) # range.exclude_start? # => true -# range.reference_title # => "Commits f3f85602^ through e86e1013" # range.to_param # => {from: "f3f856029bc5f966c5a7ee24cf7efefdd20e6019^", to: "e86e1013709735be5bb767e2b228930c543f25ae"} # range.to_s # => "f3f85602..e86e1013" # @@ -82,7 +80,7 @@ class CommitRange end def inspect - %(#<#{self.class}:#{object_id} #{to_s}>) + %(#<#{self.class}:#{object_id} #{self}>) end def to_s @@ -109,11 +107,6 @@ class CommitRange reference end - # Returns a String for use in a link's title attribute - def reference_title - "Commits #{sha_start} through #{sha_to}" - end - # Return a Hash of parameters for passing to a URL helper # # See `namespace_project_compare_url` diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 2d185c28809..7b554be4f9a 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -1,11 +1,12 @@ class CommitStatus < ActiveRecord::Base - include Statuseable + include HasStatus include Importable + include AfterCommitQueue self.table_name = 'ci_builds' belongs_to :project, class_name: '::Project', foreign_key: :gl_project_id - belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, touch: true + belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id belongs_to :user delegate :commit, to: :pipeline @@ -21,32 +22,62 @@ class CommitStatus < ActiveRecord::Base where(id: max_id.group(:name, :commit_id)) end + scope :retried, -> { where.not(id: latest) } scope :ordered, -> { order(:name) } - scope :ignored, -> { where(allow_failure: true, status: [:failed, :canceled]) } - state_machine :status, initial: :pending do - event :queue do - transition skipped: :pending + scope :failed_but_allowed, -> do + where(allow_failure: true, status: [:failed, :canceled]) + end + + scope :exclude_ignored, -> do + quoted_when = connection.quote_column_name('when') + # We want to ignore failed_but_allowed jobs + where("allow_failure = ? OR status IN (?)", + false, all_state_names - [:failed, :canceled]). + # We want to ignore skipped manual jobs + where("#{quoted_when} <> ? OR status <> ?", 'manual', 'skipped'). + # We want to ignore skipped on_failure + where("#{quoted_when} <> ? OR status <> ?", 'on_failure', 'skipped') + end + + scope :latest_ci_stages, -> { latest.ordered.includes(project: :namespace) } + scope :retried_ci_stages, -> { retried.ordered.includes(project: :namespace) } + + state_machine :status do + event :enqueue do + transition [:created, :skipped] => :pending + end + + event :process do + transition skipped: :created end event :run do transition pending: :running end + event :skip do + transition [:created, :pending] => :skipped + end + event :drop do - transition [:pending, :running] => :failed + transition [:created, :pending, :running] => :failed end event :success do - transition [:pending, :running] => :success + transition [:created, :pending, :running] => :success end event :cancel do - transition [:pending, :running] => :canceled + transition [:created, :pending, :running] => :canceled end - after_transition pending: :running do |commit_status| + after_transition created: [:pending, :running] do |commit_status| + commit_status.update_attributes queued_at: Time.now + end + + after_transition [:created, :pending] => :running do |commit_status| commit_status.update_attributes started_at: Time.now end @@ -54,12 +85,25 @@ class CommitStatus < ActiveRecord::Base commit_status.update_attributes finished_at: Time.now end - after_transition [:pending, :running] => :success do |commit_status| - MergeRequests::MergeWhenBuildSucceedsService.new(commit_status.pipeline.project, nil).trigger(commit_status) + after_transition do |commit_status, transition| + next if transition.loopback? + + commit_status.run_after_commit do + pipeline.try do |pipeline| + if complete? + PipelineProcessWorker.perform_async(pipeline.id) + else + PipelineUpdateWorker.perform_async(pipeline.id) + end + end + end end after_transition any => :failed do |commit_status| - MergeRequests::AddTodoWhenBuildFailsService.new(commit_status.pipeline.project, nil).execute(commit_status) + commit_status.run_after_commit do + MergeRequests::AddTodoWhenBuildFailsService + .new(pipeline.project, nil).execute(self) + end end end @@ -69,6 +113,10 @@ class CommitStatus < ActiveRecord::Base pipeline.before_sha || Gitlab::Git::BLANK_SHA end + def group_name + name.gsub(/\d+[\s:\/\\]+\d+\s*/, '').strip + end + def self.stages # We group by stage name, but order stages by theirs' index unscoped.from(all, :sg).group('stage').order('max(stage_idx)', 'stage').pluck('sg.stage') @@ -83,18 +131,16 @@ class CommitStatus < ActiveRecord::Base end end - def ignored? + def failed_but_allowed? allow_failure? && (failed? || canceled?) end + def playable? + false + end + def duration - duration = - if started_at && finished_at - finished_at - started_at - elsif started_at - Time.now - started_at - end - duration + calculate_duration end def stuck? diff --git a/app/models/compare.rb b/app/models/compare.rb index 4856510f526..3a8bbcb1acd 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -11,9 +11,10 @@ class Compare end end - def initialize(compare, project) + def initialize(compare, project, straight: false) @compare = compare @project = project + @straight = straight end def commits @@ -45,6 +46,18 @@ class Compare end end + def start_commit_sha + start_commit.try(:sha) + end + + def base_commit_sha + base_commit.try(:sha) + end + + def head_commit_sha + commit.try(:sha) + end + def raw_diffs(*args) @compare.diffs(*args) end @@ -58,9 +71,9 @@ class Compare def diff_refs Gitlab::Diff::DiffRefs.new( - base_sha: base_commit.try(:sha), - start_sha: start_commit.try(:sha), - head_sha: commit.try(:sha) + base_sha: @straight ? start_commit_sha : base_commit_sha, + start_sha: start_commit_sha, + head_sha: head_commit_sha ) end end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb index eedd32a729f..62bc6b809f4 100644 --- a/app/models/concerns/access_requestable.rb +++ b/app/models/concerns/access_requestable.rb @@ -8,9 +8,6 @@ module AccessRequestable extend ActiveSupport::Concern def request_access(user) - members.create( - access_level: Gitlab::Access::DEVELOPER, - user: user, - requested_at: Time.now.utc) + Members::RequestAccessService.new(self, user).execute end end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 800a16ab246..073ac4c1b65 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -2,7 +2,7 @@ module Awardable extend ActiveSupport::Concern included do - has_many :award_emoji, -> { includes(:user) }, as: :awardable, dependent: :destroy + has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy if self < Participable # By default we always load award_emoji user association @@ -59,6 +59,24 @@ module Awardable true end + def awardable_votes?(name) + AwardEmoji::UPVOTE_NAME == name || AwardEmoji::DOWNVOTE_NAME == name + end + + def user_can_award?(current_user, name) + if user_authored?(current_user) + !awardable_votes?(normalize_name(name)) + else + true + end + end + + def user_authored?(current_user) + author = self.respond_to?(:author) ? self.author : self.user + + author == current_user + end + def awarded_emoji?(emoji_name, current_user) award_emoji.where(name: emoji_name, user: current_user).exists? end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb new file mode 100644 index 00000000000..90bd6490a02 --- /dev/null +++ b/app/models/concerns/cache_markdown_field.rb @@ -0,0 +1,131 @@ +# This module takes care of updating cache columns for Markdown-containing +# fields. Use like this in the body of your class: +# +# include CacheMarkdownField +# cache_markdown_field :foo +# cache_markdown_field :bar +# cache_markdown_field :baz, pipeline: :single_line +# +# Corresponding foo_html, bar_html and baz_html fields should exist. +module CacheMarkdownField + # Knows about the relationship between markdown and html field names, and + # stores the rendering contexts for the latter + class FieldData + extend Forwardable + + def initialize + @data = {} + end + + def_delegators :@data, :[], :[]= + def_delegator :@data, :keys, :markdown_fields + + def html_field(markdown_field) + "#{markdown_field}_html" + end + + def html_fields + markdown_fields.map {|field| html_field(field) } + end + end + + # Dynamic registries don't really work in Rails as it's not guaranteed that + # every class will be loaded, so hardcode the list. + CACHING_CLASSES = %w[ + AbuseReport + Appearance + ApplicationSetting + BroadcastMessage + Issue + Label + MergeRequest + Milestone + Namespace + Note + Project + Release + Snippet + ] + + def self.caching_classes + CACHING_CLASSES.map(&:constantize) + end + + extend ActiveSupport::Concern + + included do + cattr_reader :cached_markdown_fields do + FieldData.new + end + + # Returns the default Banzai render context for the cached markdown field. + def banzai_render_context(field) + raise ArgumentError.new("Unknown field: #{field.inspect}") unless + cached_markdown_fields.markdown_fields.include?(field) + + # Always include a project key, or Banzai complains + project = self.project if self.respond_to?(:project) + context = cached_markdown_fields[field].merge(project: project) + + # Banzai is less strict about authors, so don't always have an author key + context[:author] = self.author if self.respond_to?(:author) + + context + end + + # Allow callers to look up the cache field name, rather than hardcoding it + def markdown_cache_field_for(field) + raise ArgumentError.new("Unknown field: #{field}") unless + cached_markdown_fields.markdown_fields.include?(field) + + cached_markdown_fields.html_field(field) + end + + # Always exclude _html fields from attributes (including serialization). + # They contain unredacted HTML, which would be a security issue + alias_method :attributes_before_markdown_cache, :attributes + def attributes + attrs = attributes_before_markdown_cache + + cached_markdown_fields.html_fields.each do |field| + attrs.delete(field) + end + + attrs + end + end + + class_methods do + private + + # Specify that a field is markdown. Its rendered output will be cached in + # a corresponding _html field. Any custom rendering options may be provided + # as a context. + def cache_markdown_field(markdown_field, context = {}) + raise "Add #{self} to CacheMarkdownField::CACHING_CLASSES" unless + CacheMarkdownField::CACHING_CLASSES.include?(self.to_s) + + cached_markdown_fields[markdown_field] = context + + html_field = cached_markdown_fields.html_field(markdown_field) + cache_method = "#{markdown_field}_cache_refresh".to_sym + invalidation_method = "#{html_field}_invalidated?".to_sym + + define_method(cache_method) do + html = Banzai::Renderer.cacheless_render_field(self, markdown_field) + __send__("#{html_field}=", html) + true + end + + # The HTML becomes invalid if any dependent fields change. For now, assume + # author and project invalidate the cache in all circumstances. + define_method(invalidation_method) do + changed_fields = changed_attributes.keys + invalidations = changed_fields & [markdown_field.to_s, "author", "project"] + !invalidations.empty? + end + + before_save cache_method, if: invalidation_method + end + end +end diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb new file mode 100644 index 00000000000..be93435453b --- /dev/null +++ b/app/models/concerns/expirable.rb @@ -0,0 +1,15 @@ +module Expirable + extend ActiveSupport::Concern + + included do + scope :expired, -> { where('expires_at <= ?', Time.current) } + end + + def expires? + expires_at.present? + end + + def expires_soon? + expires_at < 7.days.from_now + end +end diff --git a/app/models/concerns/faster_cache_keys.rb b/app/models/concerns/faster_cache_keys.rb new file mode 100644 index 00000000000..5b14723fa2d --- /dev/null +++ b/app/models/concerns/faster_cache_keys.rb @@ -0,0 +1,16 @@ +module FasterCacheKeys + # A faster version of Rails' "cache_key" method. + # + # Rails' default "cache_key" method uses all kind of complex logic to figure + # out the cache key. In many cases this complexity and overhead may not be + # needed. + # + # This method does not do any timestamp parsing as this process is quite + # expensive and not needed when generating cache keys. This method also relies + # on the table name instead of the cache namespace name as the latter uses + # complex logic to generate the exact same value (as when using the table + # name) in 99% of the cases. + def cache_key + "#{self.class.table_name}/#{id}-#{read_attribute_before_type_cast(:updated_at)}" + end +end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb new file mode 100644 index 00000000000..9f64f76721d --- /dev/null +++ b/app/models/concerns/has_status.rb @@ -0,0 +1,98 @@ +module HasStatus + extend ActiveSupport::Concern + + AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped] + STARTED_STATUSES = %w[running success failed skipped] + ACTIVE_STATUSES = %w[pending running] + COMPLETED_STATUSES = %w[success failed canceled] + + class_methods do + def status_sql + scope = if respond_to?(:exclude_ignored) + exclude_ignored + else + all + end + builds = scope.select('count(*)').to_sql + created = scope.created.select('count(*)').to_sql + success = scope.success.select('count(*)').to_sql + pending = scope.pending.select('count(*)').to_sql + running = scope.running.select('count(*)').to_sql + skipped = scope.skipped.select('count(*)').to_sql + canceled = scope.canceled.select('count(*)').to_sql + + "(CASE + WHEN (#{builds})=(#{success}) THEN 'success' + WHEN (#{builds})=(#{created}) THEN 'created' + WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'skipped' + WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' + WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' + WHEN (#{running})+(#{pending})+(#{created})>0 THEN 'running' + ELSE 'failed' + END)" + end + + def status + all.pluck(status_sql).first + end + + def started_at + all.minimum(:started_at) + end + + def finished_at + all.maximum(:finished_at) + end + + def all_state_names + state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) } + end + end + + included do + validates :status, inclusion: { in: AVAILABLE_STATUSES } + + state_machine :status, initial: :created do + state :created, value: 'created' + state :pending, value: 'pending' + state :running, value: 'running' + state :failed, value: 'failed' + state :success, value: 'success' + state :canceled, value: 'canceled' + state :skipped, value: 'skipped' + end + + scope :created, -> { where(status: 'created') } + scope :relevant, -> { where.not(status: 'created') } + scope :running, -> { where(status: 'running') } + scope :pending, -> { where(status: 'pending') } + scope :success, -> { where(status: 'success') } + scope :failed, -> { where(status: 'failed') } + scope :canceled, -> { where(status: 'canceled') } + scope :skipped, -> { where(status: 'skipped') } + scope :running_or_pending, -> { where(status: [:running, :pending]) } + scope :finished, -> { where(status: [:success, :failed, :canceled]) } + end + + def started? + STARTED_STATUSES.include?(status) && started_at + end + + def active? + ACTIVE_STATUSES.include?(status) + end + + def complete? + COMPLETED_STATUSES.include?(status) + end + + private + + def calculate_duration + if started_at && finished_at + finished_at - started_at + elsif started_at + Time.now - started_at + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index cbae1cd439b..c4b42ad82c7 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -6,6 +6,7 @@ # module Issuable extend ActiveSupport::Concern + include CacheMarkdownField include Participable include Mentionable include Subscribable @@ -13,6 +14,9 @@ module Issuable include Awardable included do + cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :description + belongs_to :author, class_name: "User" belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" @@ -28,10 +32,13 @@ module Issuable loaded? && to_a.all? { |note| note.association(:award_emoji).loaded? } end end + has_many :label_links, as: :target, dependent: :destroy has_many :labels, through: :label_links has_many :todos, as: :target, dependent: :destroy + has_one :metrics + validates :author, presence: true validates :title, presence: true, length: { within: 0..255 } @@ -81,12 +88,19 @@ module Issuable acts_as_paranoid after_save :update_assignee_cache_counts, if: :assignee_id_changed? + after_save :record_metrics def update_assignee_cache_counts # make sure we flush the cache for both the old *and* new assignee User.find(assignee_id_was).update_cache_counts if assignee_id_was assignee.update_cache_counts if assignee end + + # We want to use optimistic lock for cases when only title or description are involved + # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html + def locking_enabled? + title_changed? || description_changed? + end end module ClassMethods @@ -131,7 +145,10 @@ module Issuable end def order_labels_priority(excluded_labels: []) - select("#{table_name}.*, (#{highest_label_priority(excluded_labels).to_sql}) AS highest_priority"). + condition_field = "#{table_name}.id" + highest_priority = highest_label_priority(name, condition_field, excluded_labels: excluded_labels).to_sql + + select("#{table_name}.*, (#{highest_priority}) AS highest_priority"). group(arel_table[:id]). reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) end @@ -159,20 +176,6 @@ module Issuable grouping_columns end - - private - - def highest_label_priority(excluded_labels) - query = Label.select(Label.arel_table[:priority].minimum). - joins(:label_links). - where(label_links: { target_type: name }). - where("label_links.target_id = #{table_name}.id"). - reorder(nil) - - query.where.not(title: excluded_labels) if excluded_labels.present? - - query - end end def today? @@ -287,4 +290,9 @@ module Issuable def can_move?(*) false end + + def record_metrics + metrics = self.metrics || create_metrics + metrics.record! + end end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index ec9e0f1b1d0..eb2ff0428f6 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -43,19 +43,15 @@ module Mentionable self end - def all_references(current_user = nil, text = nil, extractor: nil) + def all_references(current_user = nil, extractor: nil) extractor ||= Gitlab::ReferenceExtractor. new(project, current_user) - if text - extractor.analyze(text, author: author) - else - self.class.mentionable_attrs.each do |attr, options| - text = __send__(attr) - options = options.merge(cache_key: [self, attr], author: author) + self.class.mentionable_attrs.each do |attr, options| + text = __send__(attr) + options = options.merge(cache_key: [self, attr], author: author) - extractor.analyze(text, options) - end + extractor.analyze(text, options) end extractor @@ -66,8 +62,8 @@ module Mentionable end # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. - def referenced_mentionables(current_user = self.author, text = nil) - refs = all_references(current_user, text) + def referenced_mentionables(current_user = self.author) + refs = all_references(current_user) refs = (refs.issues + refs.merge_requests + refs.commits) # We're using this method instead of Array diffing because that requires @@ -77,8 +73,8 @@ module Mentionable end # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+. - def create_cross_references!(author = self.author, without = [], text = nil) - refs = referenced_mentionables(author, text) + def create_cross_references!(author = self.author, without = []) + refs = referenced_mentionables(author) # We're using this method instead of Array diffing because that requires # both of the object's `hash` values to be the same, which may not be the @@ -97,10 +93,7 @@ module Mentionable return if changes.empty? - original_text = changes.collect { |_, vals| vals.first }.join(' ') - - preexisting = referenced_mentionables(author, original_text) - create_cross_references!(author, preexisting) + create_cross_references!(author) end private diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 4be6a2f621b..b8dd27a7afe 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -17,6 +17,10 @@ module NoteOnDiff raise NotImplementedError end + def original_line_code + raise NotImplementedError + end + def diff_attributes raise NotImplementedError end @@ -24,4 +28,8 @@ module NoteOnDiff def can_be_award_emoji? false end + + def to_discussion + Discussion.new([self]) + end end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb new file mode 100644 index 00000000000..9216122923e --- /dev/null +++ b/app/models/concerns/project_features_compatibility.rb @@ -0,0 +1,37 @@ +# Makes api V3 compatible with old project features permissions methods +# +# After migrating issues_enabled merge_requests_enabled builds_enabled snippets_enabled and wiki_enabled +# fields to a new table "project_features", support for the old fields is still needed in the API. + +module ProjectFeaturesCompatibility + extend ActiveSupport::Concern + + def wiki_enabled=(value) + write_feature_attribute(:wiki_access_level, value) + end + + def builds_enabled=(value) + write_feature_attribute(:builds_access_level, value) + end + + def merge_requests_enabled=(value) + write_feature_attribute(:merge_requests_access_level, value) + end + + def issues_enabled=(value) + write_feature_attribute(:issues_access_level, value) + end + + def snippets_enabled=(value) + write_feature_attribute(:snippets_access_level, value) + end + + private + + def write_feature_attribute(field, value) + build_project_feature unless project_feature + + access_level = value == "true" ? ProjectFeature::ENABLED : ProjectFeature::DISABLED + project_feature.update_attribute(field, access_level) + end +end diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb new file mode 100644 index 00000000000..5a7b36070e7 --- /dev/null +++ b/app/models/concerns/protected_branch_access.rb @@ -0,0 +1,7 @@ +module ProtectedBranchAccess + extend ActiveSupport::Concern + + def humanize + self.class.human_access_levels[self.access_level] + end +end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index 8b47b9e0abd..1ebecd86af9 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -35,5 +35,19 @@ module Sortable all end end + + private + + def highest_label_priority(object_types, condition_field, excluded_labels: []) + query = Label.select(Label.arel_table[:priority].minimum). + joins(:label_links). + where(label_links: { target_type: object_types }). + where("label_links.target_id = #{condition_field}"). + reorder(nil) + + query.where.not(title: excluded_labels) if excluded_labels.present? + + query + end end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 3b8e6df2da9..1aa97debe42 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -1,9 +1,32 @@ module Spammable extend ActiveSupport::Concern + module ClassMethods + def attr_spammable(attr, options = {}) + spammable_attrs << [attr.to_s, options] + end + end + included do + has_one :user_agent_detail, as: :subject, dependent: :destroy + attr_accessor :spam + after_validation :check_for_spam, on: :create + + cattr_accessor :spammable_attrs, instance_accessor: false do + [] + end + + delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true + end + + def submittable_as_spam? + if user_agent_detail + user_agent_detail.submittable? && current_application_settings.akismet_enabled + else + false + end end def spam? @@ -13,4 +36,33 @@ module Spammable def check_for_spam self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? end + + def spam_title + attr = self.class.spammable_attrs.find do |_, options| + options.fetch(:spam_title, false) + end + + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) + end + + def spam_description + attr = self.class.spammable_attrs.find do |_, options| + options.fetch(:spam_description, false) + end + + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) + end + + def spammable_text + result = self.class.spammable_attrs.map do |attr| + public_send(attr.first) + end + + result.reject(&:blank?).join("\n") + end + + # Override in Spammable if further checks are necessary + def check_for_spam? + true + end end diff --git a/app/models/concerns/statuseable.rb b/app/models/concerns/statuseable.rb deleted file mode 100644 index 44c6b30f278..00000000000 --- a/app/models/concerns/statuseable.rb +++ /dev/null @@ -1,81 +0,0 @@ -module Statuseable - extend ActiveSupport::Concern - - AVAILABLE_STATUSES = %w(pending running success failed canceled skipped) - - class_methods do - def status_sql - builds = all.select('count(*)').to_sql - success = all.success.select('count(*)').to_sql - ignored = all.ignored.select('count(*)').to_sql if all.respond_to?(:ignored) - ignored ||= '0' - pending = all.pending.select('count(*)').to_sql - running = all.running.select('count(*)').to_sql - canceled = all.canceled.select('count(*)').to_sql - skipped = all.skipped.select('count(*)').to_sql - - deduce_status = "(CASE - WHEN (#{builds})=0 THEN NULL - WHEN (#{builds})=(#{skipped}) THEN 'skipped' - WHEN (#{builds})=(#{success})+(#{ignored})+(#{skipped}) THEN 'success' - WHEN (#{builds})=(#{pending})+(#{skipped}) THEN 'pending' - WHEN (#{builds})=(#{canceled})+(#{success})+(#{ignored})+(#{skipped}) THEN 'canceled' - WHEN (#{running})+(#{pending})>0 THEN 'running' - ELSE 'failed' - END)" - - deduce_status - end - - def status - all.pluck(self.status_sql).first - end - - def duration - duration_array = all.map(&:duration).compact - duration_array.reduce(:+) - end - - def started_at - all.minimum(:started_at) - end - - def finished_at - all.maximum(:finished_at) - end - end - - included do - validates :status, inclusion: { in: AVAILABLE_STATUSES } - - state_machine :status, initial: :pending do - state :pending, value: 'pending' - state :running, value: 'running' - state :failed, value: 'failed' - state :success, value: 'success' - state :canceled, value: 'canceled' - state :skipped, value: 'skipped' - end - - scope :running, -> { where(status: 'running') } - scope :pending, -> { where(status: 'pending') } - scope :success, -> { where(status: 'success') } - scope :failed, -> { where(status: 'failed') } - scope :canceled, -> { where(status: 'canceled') } - scope :skipped, -> { where(status: 'skipped') } - scope :running_or_pending, -> { where(status: [:running, :pending]) } - scope :finished, -> { where(status: [:success, :failed, :canceled]) } - end - - def started? - !pending? && !canceled? && started_at - end - - def active? - running? || pending? - end - - def complete? - canceled? || success? || failed? - end -end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index df2a9e3e84b..a3ac577cf3e 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -52,11 +52,11 @@ module Taskable end # Return a string that describes the current state of this Taskable's task - # list items, e.g. "20 tasks (12 completed, 8 remaining)" + # list items, e.g. "12 of 20 tasks completed" def task_status return '' if description.blank? sum = tasks.summary - "#{sum.item_count} tasks (#{sum.complete_count} completed, #{sum.incomplete_count} remaining)" + "#{sum.complete_count} of #{sum.item_count} #{'task'.pluralize(sum.item_count)} completed" end end diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb new file mode 100644 index 00000000000..8ed4a56b19b --- /dev/null +++ b/app/models/cycle_analytics.rb @@ -0,0 +1,103 @@ +class CycleAnalytics + include Gitlab::Database::Median + include Gitlab::Database::DateTime + + DEPLOYMENT_METRIC_STAGES = %i[production staging] + + def initialize(project, from:) + @project = project + @from = from + end + + def summary + @summary ||= Summary.new(@project, from: @from) + end + + def issue + calculate_metric(:issue, + Issue.arel_table[:created_at], + [Issue::Metrics.arel_table[:first_associated_with_milestone_at], + Issue::Metrics.arel_table[:first_added_to_board_at]]) + end + + def plan + calculate_metric(:plan, + [Issue::Metrics.arel_table[:first_associated_with_milestone_at], + Issue::Metrics.arel_table[:first_added_to_board_at]], + Issue::Metrics.arel_table[:first_mentioned_in_commit_at]) + end + + def code + calculate_metric(:code, + Issue::Metrics.arel_table[:first_mentioned_in_commit_at], + MergeRequest.arel_table[:created_at]) + end + + def test + calculate_metric(:test, + MergeRequest::Metrics.arel_table[:latest_build_started_at], + MergeRequest::Metrics.arel_table[:latest_build_finished_at]) + end + + def review + calculate_metric(:review, + MergeRequest.arel_table[:created_at], + MergeRequest::Metrics.arel_table[:merged_at]) + end + + def staging + calculate_metric(:staging, + MergeRequest::Metrics.arel_table[:merged_at], + MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) + end + + def production + calculate_metric(:production, + Issue.arel_table[:created_at], + MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) + end + + private + + def calculate_metric(name, start_time_attrs, end_time_attrs) + cte_table = Arel::Table.new("cte_table_for_#{name}") + + # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). + # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). + # We compute the (end_time - start_time) interval, and give it an alias based on the current + # cycle analytics stage. + interval_query = Arel::Nodes::As.new( + cte_table, + subtract_datetimes(base_query_for(name), end_time_attrs, start_time_attrs, name.to_s)) + + median_datetime(cte_table, interval_query, name) + end + + # Join table with a row for every <issue,merge_request> pair (where the merge request + # closes the given issue) with issue and merge request metrics included. The metrics + # are loaded with an inner join, so issues / merge requests without metrics are + # automatically excluded. + def base_query_for(name) + arel_table = MergeRequestsClosingIssues.arel_table + + # Load issues + query = arel_table.join(Issue.arel_table).on(Issue.arel_table[:id].eq(arel_table[:issue_id])). + join(Issue::Metrics.arel_table).on(Issue.arel_table[:id].eq(Issue::Metrics.arel_table[:issue_id])). + where(Issue.arel_table[:project_id].eq(@project.id)). + where(Issue.arel_table[:deleted_at].eq(nil)). + where(Issue.arel_table[:created_at].gteq(@from)) + + # Load merge_requests + query = query.join(MergeRequest.arel_table, Arel::Nodes::OuterJoin). + on(MergeRequest.arel_table[:id].eq(arel_table[:merge_request_id])). + join(MergeRequest::Metrics.arel_table). + on(MergeRequest.arel_table[:id].eq(MergeRequest::Metrics.arel_table[:merge_request_id])) + + if DEPLOYMENT_METRIC_STAGES.include?(name) + # Limit to merge requests that have been deployed to production after `@from` + query.where(MergeRequest::Metrics.arel_table[:first_deployed_to_production_at].gteq(@from)) + end + + query + end +end diff --git a/app/models/cycle_analytics/summary.rb b/app/models/cycle_analytics/summary.rb new file mode 100644 index 00000000000..b46db449bf3 --- /dev/null +++ b/app/models/cycle_analytics/summary.rb @@ -0,0 +1,42 @@ +class CycleAnalytics + class Summary + def initialize(project, from:) + @project = project + @from = from + end + + def new_issues + @project.issues.created_after(@from).count + end + + def commits + ref = @project.default_branch.presence + count_commits_for(ref) + end + + def deploys + @project.deployments.where("created_at > ?", @from).count + end + + private + + # Don't use the `Gitlab::Git::Repository#log` method, because it enforces + # a limit. Since we need a commit count, we _can't_ enforce a limit, so + # the easiest way forward is to replicate the relevant portions of the + # `log` function here. + def count_commits_for(ref) + return unless ref + + repository = @project.repository.raw_repository + sha = @project.repository.commit(ref).sha + + cmd = %W(git --git-dir=#{repository.path} log) + cmd << '--format=%H' + cmd << "--after=#{@from.iso8601}" + cmd << sha + + raw_output = IO.popen(cmd) { |io| io.read } + raw_output.lines.count + end + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 1a7cd60817e..3d9902d496e 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -11,7 +11,7 @@ class Deployment < ActiveRecord::Base delegate :name, to: :environment, prefix: true - after_save :keep_around_commit + after_save :create_ref def commit project.commit(sha) @@ -29,11 +29,68 @@ class Deployment < ActiveRecord::Base self == environment.last_deployment end - def keep_around_commit - project.repository.keep_around(self.sha) + def create_ref + project.repository.create_ref(ref, ref_path) end def manual_actions deployable.try(:other_actions) end + + def includes_commit?(commit) + return false unless commit + + # Before 8.10, deployments didn't have keep-around refs. Any deployment + # created before then could have a `sha` referring to a commit that no + # longer exists in the repository, so just ignore those. + begin + project.repository.is_ancestor?(commit.id, sha) + rescue Rugged::OdbError + false + end + end + + def update_merge_request_metrics! + return unless environment.update_merge_request_metrics? + + merge_requests = project.merge_requests. + joins(:metrics). + where(target_branch: self.ref, merge_request_metrics: { first_deployed_to_production_at: nil }). + where("merge_request_metrics.merged_at <= ?", self.created_at) + + if previous_deployment + merge_requests = merge_requests.where("merge_request_metrics.merged_at >= ?", previous_deployment.created_at) + end + + # Need to use `map` instead of `select` because MySQL doesn't allow `SELECT`ing from the same table + # that we're updating. + merge_request_ids = + if Gitlab::Database.postgresql? + merge_requests.select(:id) + elsif Gitlab::Database.mysql? + merge_requests.map(&:id) + end + + MergeRequest::Metrics. + where(merge_request_id: merge_request_ids, first_deployed_to_production_at: nil). + update_all(first_deployed_to_production_at: self.created_at) + end + + def previous_deployment + @previous_deployment ||= + project.deployments.joins(:environment). + where(environments: { name: self.environment.name }, ref: self.ref). + where.not(id: self.id). + take + end + + def formatted_deployment_time + created_at.to_time.in_time_zone.to_s(:medium) + end + + private + + def ref_path + File.join(environment.ref_path, 'deployments', id.to_s) + end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index c816deb4e0c..559b3075905 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -9,17 +9,37 @@ class DiffNote < Note validates :diff_line, presence: true validates :line_code, presence: true, line_code: true validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] } + validates :resolved_by, presence: true, if: :resolved? validate :positions_complete validate :verify_supported + # Keep this scope in sync with the logic in `#resolvable?` + scope :resolvable, -> { user.where(noteable_type: 'MergeRequest') } + scope :resolved, -> { resolvable.where.not(resolved_at: nil) } + scope :unresolved, -> { resolvable.where(resolved_at: nil) } + + after_initialize :ensure_original_discussion_id before_validation :set_original_position, :update_position, on: :create - before_validation :set_line_code + before_validation :set_line_code, :set_original_discussion_id + # We need to do this again, because it's already in `Note`, but is affected by + # `update_position` and needs to run after that. + before_validation :set_discussion_id after_save :keep_around_commits class << self def build_discussion_id(noteable_type, noteable_id, position) [super(noteable_type, noteable_id), *position.key].join("-") end + + # This method must be kept in sync with `#resolve!` + def resolve!(current_user) + unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) + end + + # This method must be kept in sync with `#unresolve!` + def unresolve! + resolved.update_all(resolved_at: nil, resolved_by_id: nil) + end end def new_diff_note? @@ -30,14 +50,6 @@ class DiffNote < Note { position: position.to_json } end - def discussion_id - @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) - end - - def original_discussion_id - @original_discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) - end - def position=(new_position) if new_position.is_a?(String) new_position = JSON.parse(new_position) rescue nil @@ -63,6 +75,10 @@ class DiffNote < Note diff_file.position(line) == self.original_position end + def original_line_code + self.diff_file.line_code(self.diff_line) + end + def active?(diff_refs = nil) return false unless supported? return true if for_commit? @@ -72,10 +88,47 @@ class DiffNote < Note self.position.diff_refs == diff_refs end + # If you update this method remember to also update the scope `resolvable` + def resolvable? + !system? && for_merge_request? + end + + def resolved? + return false unless resolvable? + + self.resolved_at.present? + end + + # If you update this method remember to also update `.resolve!` + def resolve!(current_user) + return unless resolvable? + return if resolved? + + self.resolved_at = Time.now + self.resolved_by = current_user + save! + end + + # If you update this method remember to also update `.unresolve!` + def unresolve! + return unless resolvable? + return unless resolved? + + self.resolved_at = nil + self.resolved_by = nil + save! + end + + def discussion + return unless resolvable? + + self.noteable.find_diff_discussion(self.discussion_id) + end + private def supported? - !self.for_merge_request? || self.noteable.support_new_diff_notes? + for_commit? || self.noteable.has_complete_diff_refs? end def noteable_diff_refs @@ -94,6 +147,26 @@ class DiffNote < Note self.line_code = self.position.line_code(self.project.repository) end + def ensure_original_discussion_id + return unless self.persisted? + return if self.original_discussion_id + + set_original_discussion_id + update_column(:original_discussion_id, self.original_discussion_id) + end + + def set_original_discussion_id + self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id) + end + + def build_discussion_id + self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) + end + + def build_original_discussion_id + self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) + end + def update_position return unless supported? return if for_commit? diff --git a/app/models/discussion.rb b/app/models/discussion.rb index e2218a5f02b..de06c13481a 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -1,7 +1,7 @@ class Discussion NUMBER_OF_TRUNCATED_DIFF_LINES = 16 - attr_reader :first_note, :notes + attr_reader :notes delegate :created_at, :project, @@ -12,12 +12,19 @@ class Discussion :for_merge_request?, :line_code, + :original_line_code, :diff_file, :for_line?, :active?, to: :first_note + delegate :resolved_at, + :resolved_by, + + to: :last_resolved_note, + allow_nil: true + delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true def self.for_notes(notes) @@ -29,14 +36,29 @@ class Discussion end def initialize(notes) - @first_note = notes.first @notes = notes end + def last_resolved_note + return unless resolved? + + @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + end + + def last_updated_at + last_note.created_at + end + + def last_updated_by + last_note.author + end + def id first_note.discussion_id end + alias_method :to_param, :id + def diff_discussion? first_note.diff_note? end @@ -45,18 +67,78 @@ class Discussion notes.any?(&:legacy_diff_note?) end + def resolvable? + return @resolvable if @resolvable.present? + + @resolvable = diff_discussion? && notes.any?(&:resolvable?) + end + + def resolved? + return @resolved if @resolved.present? + + @resolved = resolvable? && notes.none?(&:to_be_resolved?) + end + + def first_note + @first_note ||= @notes.first + end + + def last_note + @last_note ||= @notes.last + end + + def resolved_notes + notes.select(&:resolved?) + end + + def to_be_resolved? + resolvable? && !resolved? + end + + def can_resolve?(current_user) + return false unless current_user + return false unless resolvable? + + current_user == self.noteable.author || + current_user.can?(:resolve_note, self.project) + end + + def resolve!(current_user) + return unless resolvable? + + update { |notes| notes.resolve!(current_user) } + end + + def unresolve! + return unless resolvable? + + update { |notes| notes.unresolve! } + end + def for_target?(target) self.noteable == target && !diff_discussion? end def active? - return @active if defined?(@active) + return @active if @active.present? @active = first_note.active? end + def collapsed? + return false unless diff_discussion? + + if resolvable? + # New diff discussions only disappear once they are marked resolved + resolved? + else + # Old diff discussions disappear once they become outdated + !active? + end + end + def expanded? - !diff_discussion? || active? + !collapsed? end def reply_attributes @@ -94,4 +176,17 @@ class Discussion prev_lines end + + private + + def update + notes_relation = DiffNote.where(id: notes.map(&:id)).fresh + yield(notes_relation) + + # Set the notes array to the updated notes + @notes = notes_relation.to_a + + # Reset the memoized values + @last_resolved_note = @resolvable = @resolved = @first_note = @last_note = nil + end end diff --git a/app/models/environment.rb b/app/models/environment.rb index baed106e8c8..d970bc0a005 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -4,6 +4,7 @@ class Environment < ActiveRecord::Base has_many :deployments before_validation :nullify_external_url + before_save :set_environment_type validates :name, presence: true, @@ -25,4 +26,44 @@ class Environment < ActiveRecord::Base def nullify_external_url self.external_url = nil if self.external_url.blank? end + + def set_environment_type + names = name.split('/') + + self.environment_type = + if names.many? + names.first + else + nil + end + end + + def includes_commit?(commit) + return false unless last_deployment + + last_deployment.includes_commit?(commit) + end + + def update_merge_request_metrics? + self.name == "production" + end + + def first_deployment_for(commit) + ref = project.repository.ref_name_for_sha(ref_path, commit.sha) + + return nil unless ref + + deployment_id = ref.split('/').last + deployments.find(deployment_id) + end + + def ref_path + "refs/environments/#{Shellwords.shellescape(name)}" + end + + def formatted_external_url + return nil unless external_url + + external_url.gsub(/\A.*?:\/\//, '') + end end diff --git a/app/models/event.rb b/app/models/event.rb index fd736d12359..0764cb8cabd 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -13,6 +13,8 @@ class Event < ActiveRecord::Base LEFT = 9 # User left project DESTROYED = 10 + RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour + delegate :name, :email, to: :author, prefix: true, allow_nil: true delegate :title, to: :issue, prefix: true, allow_nil: true delegate :title, to: :merge_request, prefix: true, allow_nil: true @@ -65,9 +67,11 @@ class Event < ActiveRecord::Base elsif created_project? true elsif issue? || issue_note? - Ability.abilities.allowed?(user, :read_issue, note? ? note_target : target) + Ability.allowed?(user, :read_issue, note? ? note_target : target) + elsif merge_request? || merge_request_note? + Ability.allowed?(user, :read_merge_request, note? ? note_target : target) else - ((merge_request? || note?) && target.present?) || milestone? + milestone? end end @@ -278,6 +282,10 @@ class Event < ActiveRecord::Base note? && target && target.for_issue? end + def merge_request_note? + note? && target && target.for_merge_request? + end + def project_snippet_note? target.for_snippet? end @@ -324,8 +332,22 @@ class Event < ActiveRecord::Base end def reset_project_activity - if project && Gitlab::ExclusiveLease.new("project:update_last_activity_at:#{project.id}", timeout: 60).try_obtain - project.update_column(:last_activity_at, self.created_at) - end + return unless project + + # Don't bother updating if we know the project was updated recently. + return if recent_update? + + # At this point it's possible for multiple threads/processes to try to + # update the project. Only one query should actually perform the update, + # hence we add the extra WHERE clause for last_activity_at. + Project.unscoped.where(id: project_id). + where('last_activity_at <= ?', RESET_PROJECT_ACTIVITY_INTERVAL.ago). + update_all(last_activity_at: created_at) + end + + private + + def recent_update? + project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago end end diff --git a/app/models/global_label.rb b/app/models/global_label.rb index ddd4bad5c21..698a7bbd327 100644 --- a/app/models/global_label.rb +++ b/app/models/global_label.rb @@ -4,6 +4,10 @@ class GlobalLabel delegate :color, :description, to: :@first_label + def for_display + @first_label + end + def self.build_collection(labels) labels = labels.group_by(&:title) diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index da7c265a371..cde4a568577 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -4,11 +4,16 @@ class GlobalMilestone attr_accessor :title, :milestones alias_attribute :name, :title + def for_display + @first_milestone + end + def self.build_collection(milestones) milestones = milestones.group_by(&:title) milestones.map do |title, milestones| - new(title, milestones) + milestones_relation = Milestone.where(id: milestones.map(&:id)) + new(title, milestones_relation) end end @@ -16,6 +21,7 @@ class GlobalMilestone @title = title @name = title @milestones = milestones + @first_milestone = milestones.find {|m| m.description.present? } || milestones.first end def safe_title @@ -31,7 +37,7 @@ class GlobalMilestone end def projects - @projects ||= Project.for_milestones(milestones.map(&:id)) + @projects ||= Project.for_milestones(milestones.select(:id)) end def state @@ -53,19 +59,19 @@ class GlobalMilestone end def issues - @issues ||= Issue.of_milestones(milestones.map(&:id)).includes(:project) + @issues ||= Issue.of_milestones(milestones.select(:id)).includes(:project, :assignee, :labels) end def merge_requests - @merge_requests ||= MergeRequest.of_milestones(milestones.map(&:id)).includes(:target_project) + @merge_requests ||= MergeRequest.of_milestones(milestones.select(:id)).includes(:target_project, :assignee, :labels) end def participants - @participants ||= milestones.map(&:participants).flatten.compact.uniq + @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq end def labels - @labels ||= GlobalLabel.build_collection(milestones.map(&:labels).flatten) + @labels ||= GlobalLabel.build_collection(milestones.includes(:labels).map(&:labels).flatten) .sort_by!(&:title) end diff --git a/app/models/group.rb b/app/models/group.rb index 37631b99701..a2f88cca828 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -95,34 +95,51 @@ class Group < Namespace end end - def add_users(user_ids, access_level, current_user = nil) - user_ids.each do |user_id| - Member.add_user(self.group_members, user_id, access_level, current_user) - end + def lfs_enabled? + return false unless Gitlab.config.lfs.enabled + return Gitlab.config.lfs.enabled if self[:lfs_enabled].nil? + + self[:lfs_enabled] + end + + def add_users(users, access_level, current_user: nil, expires_at: nil) + GroupMember.add_users_to_group( + self, + users, + access_level, + current_user: current_user, + expires_at: expires_at + ) end - def add_user(user, access_level, current_user = nil) - add_users([user], access_level, current_user) + def add_user(user, access_level, current_user: nil, expires_at: nil) + GroupMember.add_user( + self, + user, + access_level, + current_user: current_user, + expires_at: expires_at + ) end def add_guest(user, current_user = nil) - add_user(user, Gitlab::Access::GUEST, current_user) + add_user(user, :guest, current_user: current_user) end def add_reporter(user, current_user = nil) - add_user(user, Gitlab::Access::REPORTER, current_user) + add_user(user, :reporter, current_user: current_user) end def add_developer(user, current_user = nil) - add_user(user, Gitlab::Access::DEVELOPER, current_user) + add_user(user, :developer, current_user: current_user) end def add_master(user, current_user = nil) - add_user(user, Gitlab::Access::MASTER, current_user) + add_user(user, :master, current_user: current_user) end def add_owner(user, current_user = nil) - add_user(user, Gitlab::Access::OWNER, current_user) + add_user(user, :owner, current_user: current_user) end def has_owner?(user) diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index ba42a8eeb70..c631e7a7df5 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -2,8 +2,10 @@ class ProjectHook < WebHook belongs_to :project scope :issue_hooks, -> { where(issues_events: true) } + scope :confidential_issue_hooks, -> { where(confidential_issues_events: true) } scope :note_hooks, -> { where(note_events: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true) } scope :build_hooks, -> { where(build_events: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true) } end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 8b87b6c3d64..595602e80fe 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -4,10 +4,12 @@ class WebHook < ActiveRecord::Base default_value_for :push_events, true default_value_for :issues_events, false + default_value_for :confidential_issues_events, false default_value_for :note_events, false default_value_for :merge_requests_events, false default_value_for :tag_push_events, false default_value_for :build_events, false + default_value_for :pipeline_events, false default_value_for :enable_ssl_verification, true scope :push_hooks, -> { where(push_events: true) } diff --git a/app/models/issue.rb b/app/models/issue.rb index 11f734cfc6d..abd58e0454a 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -7,6 +7,7 @@ class Issue < ActiveRecord::Base include Sortable include Taskable include Spammable + include FasterCacheKeys DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -22,6 +23,8 @@ class Issue < ActiveRecord::Base has_many :events, as: :target, dependent: :destroy + has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + validates :project, presence: true scope :cared, ->(user) { where(assignee_id: user) } @@ -35,6 +38,11 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } + scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } + + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -261,4 +269,9 @@ class Issue < ActiveRecord::Base def overdue? due_date.try(:past?) || false end + + # Only issues on public projects should be checked for spam + def check_for_spam? + project.public? + end end diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb new file mode 100644 index 00000000000..012d545c440 --- /dev/null +++ b/app/models/issue/metrics.rb @@ -0,0 +1,21 @@ +class Issue::Metrics < ActiveRecord::Base + belongs_to :issue + + def record! + if issue.milestone_id.present? && self.first_associated_with_milestone_at.blank? + self.first_associated_with_milestone_at = Time.now + end + + if issue_assigned_to_list_label? && self.first_added_to_board_at.blank? + self.first_added_to_board_at = Time.now + end + + self.save + end + + private + + def issue_assigned_to_list_label? + issue.labels.any? { |label| label.lists.present? } + end +end diff --git a/app/models/label.rb b/app/models/label.rb index 35e678001dc..e8e12e2904e 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -1,4 +1,5 @@ class Label < ActiveRecord::Base + include CacheMarkdownField include Referable include Subscribable @@ -8,11 +9,15 @@ class Label < ActiveRecord::Base None = LabelStruct.new('No Label', 'No Label') Any = LabelStruct.new('Any Label', '') + cache_markdown_field :description, pipeline: :single_line + DEFAULT_COLOR = '#428BCA' default_value_for :color, DEFAULT_COLOR belongs_to :project + + has_many :lists, dependent: :destroy has_many :label_links, dependent: :destroy has_many :issues, through: :label_links, source: :target, source_type: 'Issue' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 6ed66001513..40277a9b139 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -8,8 +8,8 @@ class LegacyDiffNote < Note before_create :set_diff class << self - def build_discussion_id(noteable_type, noteable_id, line_code, active = true) - [super(noteable_type, noteable_id), line_code, active].join("-") + def build_discussion_id(noteable_type, noteable_id, line_code) + [super(noteable_type, noteable_id), line_code].join("-") end end @@ -21,10 +21,6 @@ class LegacyDiffNote < Note { line_code: line_code } end - def discussion_id - @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) - end - def project_repository if RequestStore.active? RequestStore.fetch("project:#{project_id}:repository") { self.project.repository } @@ -53,6 +49,10 @@ class LegacyDiffNote < Note !line.meta? && diff_file.line_code(line) == self.line_code end + def original_line_code + self.line_code + end + # Check if this note is part of an "active" discussion # # This will always return true for anything except MergeRequest noteables, @@ -119,4 +119,8 @@ class LegacyDiffNote < Note diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end + + def build_discussion_id + self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) + end end diff --git a/app/models/list.rb b/app/models/list.rb new file mode 100644 index 00000000000..eb87decdbc8 --- /dev/null +++ b/app/models/list.rb @@ -0,0 +1,34 @@ +class List < ActiveRecord::Base + belongs_to :board + belongs_to :label + + enum list_type: { backlog: 0, label: 1, done: 2 } + + validates :board, :list_type, presence: true + validates :label, :position, presence: true, if: :label? + validates :label_id, uniqueness: { scope: :board_id }, if: :label? + validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label? + + before_destroy :can_be_destroyed + + scope :destroyable, -> { where(list_type: list_types[:label]) } + scope :movable, -> { where(list_type: list_types[:label]) } + + def destroyable? + label? + end + + def movable? + label? + end + + def title + label? ? label.name : list_type.humanize + end + + private + + def can_be_destroyed + destroyable? + end +end diff --git a/app/models/member.rb b/app/models/member.rb index 24ab1276ee9..b89ba8ecbb8 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,6 +1,7 @@ class Member < ActiveRecord::Base include Sortable include Importable + include Expirable include Gitlab::Access attr_accessor :raw_invite_token @@ -27,17 +28,34 @@ class Member < ActiveRecord::Base allow_nil: true } + # This scope encapsulates (most of) the conditions a row in the member table + # must satisfy if it is a valid permission. Of particular note: + # + # * Access requests must be excluded + # * Blocked users must be excluded + # * Invitations take effect immediately + # * expires_at is not implemented. A background worker purges expired rows + scope :active, -> do + is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil)) + user_is_active = User.arel_table[:state].eq(:active) + + includes(:user).references(:users) + .where(is_external_invite.or(user_is_active)) + .where(requested_at: nil) + end + scope :invite, -> { where.not(invite_token: nil) } scope :non_invite, -> { where(invite_token: nil) } scope :request, -> { where.not(requested_at: nil) } - scope :has_access, -> { where('access_level > 0') } - scope :guests, -> { where(access_level: GUEST) } - scope :reporters, -> { where(access_level: REPORTER) } - scope :developers, -> { where(access_level: DEVELOPER) } - scope :masters, -> { where(access_level: MASTER) } - scope :owners, -> { where(access_level: OWNER) } - scope :owners_and_masters, -> { where(access_level: [OWNER, MASTER]) } + scope :has_access, -> { active.where('access_level > 0') } + + scope :guests, -> { active.where(access_level: GUEST) } + scope :reporters, -> { active.where(access_level: REPORTER) } + scope :developers, -> { active.where(access_level: DEVELOPER) } + scope :masters, -> { active.where(access_level: MASTER) } + scope :owners, -> { active.where(access_level: OWNER) } + scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } @@ -62,48 +80,75 @@ class Member < ActiveRecord::Base find_by(invite_token: invite_token) end - # This method is used to find users that have been entered into the "Add members" field. - # These can be the User objects directly, their IDs, their emails, or new emails to be invited. - def user_for_id(user_id) - return user_id if user_id.is_a?(User) - - user = User.find_by(id: user_id) - user ||= User.find_by(email: user_id) - user ||= user_id - user - end - - def add_user(members, user_id, access_level, current_user = nil) - user = user_for_id(user_id) + def add_user(source, user, access_level, current_user: nil, expires_at: nil) + user = retrieve_user(user) + access_level = retrieve_access_level(access_level) # `user` can be either a User object or an email to be invited - if user.is_a?(User) - member = members.find_or_initialize_by(user_id: user.id) + member = + if user.is_a?(User) + source.members.find_by(user_id: user.id) || + source.requesters.find_by(user_id: user.id) || + source.members.build(user_id: user.id) + else + source.members.build(invite_email: user) + end + + return member unless can_update_member?(current_user, member) + + member.attributes = { + created_by: member.created_by || current_user, + access_level: access_level, + expires_at: expires_at + } + + if member.request? + ::Members::ApproveAccessRequestService.new( + source, + current_user, + id: member.id, + access_level: access_level + ).execute else - member = members.build - member.invite_email = user + member.save end - if can_update_member?(current_user, member) || project_creator?(member, access_level) - member.created_by ||= current_user - member.access_level = access_level + member + end - member.save - end + def access_levels + Gitlab::Access.sym_options end private + # This method is used to find users that have been entered into the "Add members" field. + # These can be the User objects directly, their IDs, their emails, or new emails to be invited. + def retrieve_user(user) + return user if user.is_a?(User) + + User.find_by(id: user) || User.find_by(email: user) || user + end + + def retrieve_access_level(access_level) + access_levels.fetch(access_level) { access_level.to_i } + end + def can_update_member?(current_user, member) # There is no current user for bulk actions, in which case anything is allowed - !current_user || - current_user.can?(:update_group_member, member) || - current_user.can?(:update_project_member, member) + !current_user || current_user.can?(:"update_#{member.type.underscore}", member) end - def project_creator?(member, access_level) - member.new_record? && member.owner? && - access_level.to_i == ProjectMember::MASTER + def add_users_to_source(source, users, access_level, current_user: nil, expires_at: nil) + users.each do |user| + add_user( + source, + user, + access_level, + current_user: current_user, + expires_at: expires_at + ) + end end end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 2f13d339c89..1b54a85d064 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -12,6 +12,22 @@ class GroupMember < Member Gitlab::Access.options_with_owner end + def self.access_levels + Gitlab::Access.sym_options_with_owner + end + + def self.add_users_to_group(group, users, access_level, current_user: nil, expires_at: nil) + self.transaction do + add_users_to_source( + group, + users, + access_level, + current_user: current_user, + expires_at: expires_at + ) + end + end + def group source end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index f39afc61ce9..125f26369d7 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -8,6 +8,7 @@ class ProjectMember < Member # Make sure project member points only to project as it source default_value_for :source_type, SOURCE_TYPE validates_format_of :source_type, with: /\AProject\z/ + validates :access_level, inclusion: { in: Gitlab::Access.values } default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } @@ -21,42 +22,32 @@ class ProjectMember < Member # or symbol like :master representing role # # Ex. - # add_users_into_projects( + # add_users_to_projects( # project_ids, # user_ids, # ProjectMember::MASTER # ) # - # add_users_into_projects( + # add_users_to_projects( # project_ids, # user_ids, # :master # ) # - def add_users_into_projects(project_ids, user_ids, access, current_user = nil) - access_level = if roles_hash.has_key?(access) - roles_hash[access] - elsif roles_hash.values.include?(access.to_i) - access - else - raise "Non valid access" - end - - users = user_ids.map { |user_id| Member.user_for_id(user_id) } - - ProjectMember.transaction do + def add_users_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil) + self.transaction do project_ids.each do |project_id| project = Project.find(project_id) - users.each do |user| - Member.add_user(project.project_members, user, access_level, current_user) - end + add_users_to_source( + project, + users, + access_level, + current_user: current_user, + expires_at: expires_at + ) end end - - true - rescue - false end def truncate_teams(project_ids) @@ -77,13 +68,15 @@ class ProjectMember < Member truncate_teams [project.id] end - def roles_hash - Gitlab::Access.sym_options - end - def access_level_roles Gitlab::Access.options end + + private + + def can_update_member?(current_user, member) + super || (member.owner? && member.new_record?) + end end def access_field diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 6b0d5726199..78dbe91670c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -10,12 +10,16 @@ class MergeRequest < ActiveRecord::Base belongs_to :source_project, foreign_key: :source_project_id, class_name: "Project" belongs_to :merge_user, class_name: "User" - has_one :merge_request_diff, dependent: :destroy + has_many :merge_request_diffs, dependent: :destroy + has_one :merge_request_diff, + -> { order('merge_request_diffs.id DESC') } has_many :events, as: :target, dependent: :destroy - after_create :create_merge_request_diff, unless: :importing? - after_update :update_merge_request_diff + has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + + after_create :ensure_merge_request_diff, unless: :importing? + after_update :reload_diff_if_branch_changed delegate :commits, :real_size, to: :merge_request_diff, prefix: nil @@ -25,7 +29,7 @@ class MergeRequest < ActiveRecord::Base # Temporary fields to store compare vars # when creating new merge request - attr_accessor :can_be_created, :compare_commits, :compare + attr_accessor :can_be_created, :compare_commits, :diff_options, :compare state_machine :state, initial: :opened do event :close do @@ -87,13 +91,13 @@ class MergeRequest < ActiveRecord::Base end end - validates :source_project, presence: true, unless: [:allow_broken, :importing?] + validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?] validates :source_branch, presence: true validates :target_project, presence: true validates :target_branch, presence: true validates :merge_user, presence: true, if: :merge_when_build_succeeds? - validate :validate_branches, unless: [:allow_broken, :importing?] - validate :validate_fork + validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] + validate :validate_fork, unless: :closed_without_fork? scope :by_branch, ->(branch_name) { where("(source_branch LIKE :branch) OR (target_branch LIKE :branch)", branch: branch_name) } scope :cared, ->(user) { where('assignee_id = :user OR author_id = :user', user: user.id) } @@ -102,6 +106,7 @@ class MergeRequest < ActiveRecord::Base scope :from_project, ->(project) { where(source_project_id: project.id) } scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } + scope :from_source_branches, ->(branches) { where(source_branch: branches) } scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } @@ -148,6 +153,20 @@ class MergeRequest < ActiveRecord::Base where("merge_requests.id IN (#{union.to_sql})") end + WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze + + def self.work_in_progress?(title) + !!(title =~ WIP_REGEX) + end + + def self.wipless_title(title) + title.sub(WIP_REGEX, "") + end + + def self.wip_title(title) + work_in_progress?(title) ? title : "WIP: #{title}" + end + def to_reference(from_project = nil) reference = "#{self.class.reference_prefix}#{iid}" @@ -167,22 +186,22 @@ class MergeRequest < ActiveRecord::Base end def diffs(diff_options = nil) - if self.compare - self.compare.diffs(diff_options) + if compare + compare.diffs(diff_options) else - Gitlab::Diff::FileCollection::MergeRequest.new(self, diff_options: diff_options) + merge_request_diff.diffs(diff_options) end end def diff_size - merge_request_diff.size + diffs(diff_options).size end def diff_base_commit if persisted? merge_request_diff.base_commit - elsif diff_start_commit && diff_head_commit - self.target_project.merge_base_commit(diff_start_sha, diff_head_sha) + else + branch_merge_base_commit end end @@ -235,12 +254,21 @@ class MergeRequest < ActiveRecord::Base def source_branch_head source_branch_ref = @source_branch_sha || source_branch - source_project.repository.commit(source_branch) if source_branch_ref + source_project.repository.commit(source_branch_ref) if source_branch_ref end def target_branch_head target_branch_ref = @target_branch_sha || target_branch - target_project.repository.commit(target_branch) if target_branch_ref + target_project.repository.commit(target_branch_ref) if target_branch_ref + end + + def branch_merge_base_commit + start_sha = target_branch_sha + head_sha = source_branch_sha + + if start_sha && head_sha + target_project.merge_base_commit(start_sha, head_sha) + end end def target_branch_sha @@ -264,16 +292,16 @@ class MergeRequest < ActiveRecord::Base # Return diff_refs instance trying to not touch the git repository def diff_sha_refs if merge_request_diff && merge_request_diff.diff_refs_by_sha? - return Gitlab::Diff::DiffRefs.new( - base_sha: merge_request_diff.base_commit_sha, - start_sha: merge_request_diff.start_commit_sha, - head_sha: merge_request_diff.head_commit_sha - ) + merge_request_diff.diff_refs else diff_refs end end + def branch_merge_base_sha + branch_merge_base_commit.try(:sha) + end + def validate_branches if target_project == source_project && target_branch == source_branch errors.add :branch_conflict, "You can not use same project/branch for source and target" @@ -291,36 +319,59 @@ class MergeRequest < ActiveRecord::Base def validate_fork return true unless target_project && source_project + return true if target_project == source_project + return true unless forked_source_project_missing? - if target_project == source_project - true - else - # If source and target projects are different - # we should check if source project is actually a fork of target project - if source_project.forked_from?(target_project) - true - else - errors.add :validate_fork, - 'Source project is not a fork of target project' - end - end + errors.add :validate_fork, + 'Source project is not a fork of the target project' + end + + def closed_without_fork? + closed? && forked_source_project_missing? end - def update_merge_request_diff + def closed_without_source_project? + closed? && !source_project + end + + def forked_source_project_missing? + return false unless for_fork? + return true unless source_project + + !source_project.forked_from?(target_project) + end + + def reopenable? + return false if closed_without_fork? || closed_without_source_project? || merged? + + closed? + end + + def ensure_merge_request_diff + merge_request_diff || create_merge_request_diff + end + + def create_merge_request_diff + merge_request_diffs.create + reload_merge_request_diff + end + + def reload_merge_request_diff + merge_request_diff(true) + end + + def reload_diff_if_branch_changed if source_branch_changed? || target_branch_changed? reload_diff end end def reload_diff - return unless merge_request_diff && open? + return unless open? old_diff_refs = self.diff_refs - - merge_request_diff.reload_content - + create_merge_request_diff MergeRequests::MergeRequestDiffCacheService.new.execute(self) - new_diff_refs = self.diff_refs update_diff_notes_positions( @@ -350,14 +401,16 @@ class MergeRequest < ActiveRecord::Base @closed_event ||= target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: Event::CLOSED).last end - WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze - def work_in_progress? - !!(title =~ WIP_REGEX) + self.class.work_in_progress?(title) end def wipless_title - self.title.sub(WIP_REGEX, "") + self.class.wipless_title(self.title) + end + + def wip_title + self.class.wip_title(self.title) end def mergeable?(skip_ci_check: false) @@ -384,7 +437,7 @@ class MergeRequest < ActiveRecord::Base def can_remove_source_branch?(current_user) !source_project.protected_branch?(source_branch) && !source_project.root_ref?(source_branch) && - Ability.abilities.allowed?(current_user, :push_code, source_project) && + Ability.allowed?(current_user, :push_code, source_project) && diff_head_commit == source_branch_head && !same_source_branch_merge_requests? end @@ -404,6 +457,32 @@ class MergeRequest < ActiveRecord::Base ) end + def discussions + @discussions ||= self.mr_and_commit_notes. + inc_relations_for_view. + fresh. + discussions + end + + def diff_discussions + @diff_discussions ||= self.notes.diff_notes.discussions + end + + def find_diff_discussion(discussion_id) + notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? + + Discussion.new(notes) + end + + def discussions_resolvable? + diff_discussions.any?(&:resolvable?) + end + + def discussions_resolved? + discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?) + end + def hook_attrs attrs = { source: source_project.try(:hook_attrs), @@ -427,6 +506,23 @@ class MergeRequest < ActiveRecord::Base target_project end + # If the merge request closes any issues, save this information in the + # `MergeRequestsClosingIssues` model. This is a performance optimization. + # Calculating this information for a number of merge requests requires + # running `ReferenceExtractor` on each of them separately. + # This optimization does not apply to issues from external sources. + def cache_merge_request_closes_issues!(current_user = self.author) + return if project.has_external_issue_tracker? + + transaction do + self.merge_requests_closing_issues.delete_all + + closes_issues(current_user).each do |issue| + self.merge_requests_closing_issues.create!(issue: issue) + end + end + end + def closes_issue?(issue) closes_issues.include?(issue) end @@ -434,7 +530,8 @@ class MergeRequest < ActiveRecord::Base # Return the set of issues that will be closed if this merge request is accepted. def closes_issues(current_user = self.author) if target_branch == project.default_branch - messages = commits.map(&:safe_message) << description + messages = [description] + messages.concat(commits.map(&:safe_message)) if merge_request_diff Gitlab::ClosingIssueExtractor.new(project, current_user). closed_by_message(messages.join("\n")) @@ -487,8 +584,9 @@ class MergeRequest < ActiveRecord::Base self.target_project.repository.branch_names.include?(self.target_branch) end + # Is there an open merge request with the same branch as ours? def same_source_branch_merge_requests? - target_project.merge_requests.opened.where(source_branch: source_branch).exists? + target_project.merge_requests.opened.where(source_branch: source_branch).where.not(id: id).exists? end # Reset merge request events cache @@ -504,13 +602,11 @@ class MergeRequest < ActiveRecord::Base end def merge_commit_message - message = "Merge branch '#{source_branch}' into '#{target_branch}'" - message << "\n\n" - message << title.to_s - message << "\n\n" - message << description.to_s - message << "\n\n" - message << "See merge request !#{iid}" + message = "Merge branch '#{source_branch}' into '#{target_branch}'\n\n" + message << "#{title}\n\n" + message << "#{description}\n\n" if description.present? + message << "See merge request #{to_reference}" + message end @@ -576,6 +672,20 @@ class MergeRequest < ActiveRecord::Base !pipeline || pipeline.success? end + def environments + return [] unless diff_head_commit + + @environments ||= + begin + environments = source_project.environments_for( + source_branch, diff_head_commit) + environments += target_project.environments_for( + target_branch, diff_head_commit, with_tags: true) + + environments.uniq + end + end + def state_human_name if merged? "Merged" @@ -651,8 +761,34 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end + def commits_sha + commits.map(&:sha) + end + def pipeline - @pipeline ||= source_project.pipeline(diff_head_sha, source_branch) if diff_head_sha && source_project + return unless diff_head_sha && source_project + + @pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha) + end + + def all_pipelines + return unless source_project + + @all_pipelines ||= begin + sha = if persisted? + all_commits_sha + else + diff_head_sha + end + + source_project.pipelines.order(id: :desc). + where(sha: sha, ref: source_branch) + end + end + + # Note that this could also return SHA from now dangling commits + def all_commits_sha + merge_request_diffs.flat_map(&:commits_sha).uniq end def merge_commit @@ -667,12 +803,12 @@ class MergeRequest < ActiveRecord::Base merge_commit end - def support_new_diff_notes? + def has_complete_diff_refs? diff_sha_refs && diff_sha_refs.complete? end def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) - return unless support_new_diff_notes? + return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs active_diff_notes = self.notes.diff_notes.select do |note| @@ -700,4 +836,30 @@ class MergeRequest < ActiveRecord::Base def keep_around_commit project.repository.keep_around(self.merge_commit_sha) end + + def conflicts + @conflicts ||= Gitlab::Conflict::FileCollection.new(self) + end + + def conflicts_can_be_resolved_by?(user) + access = ::Gitlab::UserAccess.new(user, project: source_project) + access.can_push_to_branch?(source_branch) + end + + def conflicts_can_be_resolved_in_ui? + return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) + + return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged? + return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs? + + begin + # Try to parse each conflict. If the MR's mergeable status hasn't been updated, + # ensure that we don't say there are conflicts to resolve when there are no conflict + # files. + conflicts.files.each(&:lines) + @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0 + rescue Rugged::OdbError, Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing + @conflicts_can_be_resolved_in_ui = false + end + end end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb new file mode 100644 index 00000000000..99c49a020c9 --- /dev/null +++ b/app/models/merge_request/metrics.rb @@ -0,0 +1,11 @@ +class MergeRequest::Metrics < ActiveRecord::Base + belongs_to :merge_request + + def record! + if merge_request.merged? && self.merged_at.blank? + self.merged_at = Time.now + end + + self.save + end +end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index fa0efe2d596..b8a10b7968e 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -6,9 +6,10 @@ class MergeRequestDiff < ActiveRecord::Base # Prevent store of diff if commits amount more then 500 COMMITS_SAFE_SIZE = 100 - belongs_to :merge_request + # Valid types of serialized diffs allowed by Gitlab::Git::Diff + VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta] - delegate :source_branch_sha, :target_branch_sha, :target_branch, :source_branch, to: :merge_request, prefix: nil + belongs_to :merge_request state_machine :state, initial: :empty do state :collected @@ -24,28 +25,64 @@ class MergeRequestDiff < ActiveRecord::Base serialize :st_commits serialize :st_diffs - after_create :reload_content, unless: :importing? - after_save :keep_around_commits, unless: :importing? + # All diff information is collected from repository after object is created. + # It allows you to override variables like head_commit_sha before getting diff. + after_create :save_git_content, unless: :importing? + + def self.select_without_diff + select(column_names - ['st_diffs']) + end + + def st_commits + super || [] + end - def reload_content + # Collect information about commits and diff from repository + # and save it to the database as serialized data + def save_git_content + ensure_commits_sha + save_commits reload_commits - reload_diffs + save_diffs + keep_around_commits + end + + def ensure_commits_sha + merge_request.fetch_ref + self.start_commit_sha ||= merge_request.target_branch_sha + self.head_commit_sha ||= merge_request.source_branch_sha + self.base_commit_sha ||= find_base_sha + save + end + + # Override head_commit_sha to keep compatibility with merge request diff + # created before version 8.4 that does not store head_commit_sha in separate db field. + def head_commit_sha + if persisted? && super.nil? + last_commit.try(:sha) + else + super + end + end + + # This method will rely on repository branch sha + # in case start_commit_sha is nil. Its necesarry for old merge request diff + # created before version 8.4 to work + def safe_start_commit_sha + start_commit_sha || merge_request.target_branch_sha end def size real_size.presence || raw_diffs.size end - def raw_diffs(options={}) + def raw_diffs(options = {}) if options[:ignore_whitespace_change] - @raw_diffs_no_whitespace ||= begin - compare = Gitlab::Git::Compare.new( + @diffs_no_whitespace ||= + Gitlab::Git::Compare.new( repository.raw_repository, - self.start_commit_sha || self.target_branch_sha, - self.head_commit_sha || self.source_branch_sha, - ) - compare.diffs(options) - end + safe_start_commit_sha, + head_commit_sha).diffs(options) else @raw_diffs ||= {} @raw_diffs[options] ||= load_diffs(st_diffs, options) @@ -53,7 +90,12 @@ class MergeRequestDiff < ActiveRecord::Base end def commits - @commits ||= load_commits(st_commits || []) + @commits ||= load_commits(st_commits) + end + + def reload_commits + @commits = nil + commits end def last_commit @@ -65,53 +107,82 @@ class MergeRequestDiff < ActiveRecord::Base end def base_commit - return unless self.base_commit_sha + return unless base_commit_sha - project.commit(self.base_commit_sha) + project.commit(base_commit_sha) end def start_commit - return unless self.start_commit_sha + return unless start_commit_sha - project.commit(self.start_commit_sha) + project.commit(start_commit_sha) end def head_commit - return last_commit unless self.head_commit_sha + return unless head_commit_sha - project.commit(self.head_commit_sha) + project.commit(head_commit_sha) + end + + def commits_sha + if @commits + commits.map(&:sha) + else + st_commits.map { |commit| commit[:id] } + end + end + + def diff_refs + return unless start_commit_sha || base_commit_sha + + Gitlab::Diff::DiffRefs.new( + base_sha: base_commit_sha, + start_sha: start_commit_sha, + head_sha: head_commit_sha + ) end def diff_refs_by_sha? base_commit_sha? && head_commit_sha? && start_commit_sha? end + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::MergeRequestDiff.new(self, diff_options: diff_options) + end + + def project + merge_request.target_project + end + def compare @compare ||= - begin - # Update ref for merge request - merge_request.fetch_ref + Gitlab::Git::Compare.new( + repository.raw_repository, + safe_start_commit_sha, + head_commit_sha + ) + end - Gitlab::Git::Compare.new( - repository.raw_repository, - self.target_branch_sha, - self.source_branch_sha - ) - end + def latest? + self == merge_request.merge_request_diff end - private + def compare_with(sha, straight: true) + # When compare merge request versions we want diff A..B instead of A...B + # so we handle cases when user does squash and rebase of the commits between versions. + # For this reason we set straight to true by default. + CompareService.new.execute(project, head_commit_sha, project, sha, straight: straight) + end - # Collect array of Git::Commit objects - # between target and source branches - def unmerged_commits - commits = compare.commits + private - if commits.present? - commits = Commit.decorate(commits, merge_request.source_project).reverse - end + # Old GitLab implementations may have generated diffs as ["--broken-diff"]. + # Avoid an error 500 by ignoring bad elements. See: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/20776 + def valid_raw_diff?(raw) + return false unless raw.respond_to?(:each) - commits + raw.any? { |element| VALID_CLASSES.include?(element.class) } end def dump_commits(commits) @@ -122,26 +193,21 @@ class MergeRequestDiff < ActiveRecord::Base array.map { |hash| Commit.new(Gitlab::Git::Commit.new(hash), merge_request.source_project) } end - # Reload all commits related to current merge request from repo + # Load all commits related to current merge request diff from repo # and save it as array of hashes in st_commits db field - def reload_commits + def save_commits new_attributes = {} - commit_objects = unmerged_commits + commits = compare.commits - if commit_objects.present? - new_attributes[:st_commits] = dump_commits(commit_objects) + if commits.present? + commits = Commit.decorate(commits, merge_request.source_project).reverse + new_attributes[:st_commits] = dump_commits(commits) end update_columns_serialized(new_attributes) end - # Collect array of Git::Diff objects - # between target and source branches - def unmerged_diffs - compare.diffs(Commit.max_diff_options) - end - def dump_diffs(diffs) if diffs.respond_to?(:map) diffs.map(&:to_hash) @@ -149,7 +215,7 @@ class MergeRequestDiff < ActiveRecord::Base end def load_diffs(raw, options) - if raw.respond_to?(:each) + if valid_raw_diff?(raw) if paths = options[:paths] raw = raw.select do |diff| paths.include?(diff[:old_path]) || paths.include?(diff[:new_path]) @@ -162,16 +228,16 @@ class MergeRequestDiff < ActiveRecord::Base end end - # Reload diffs between branches related to current merge request from repo + # Load diffs between branches related to current merge request diff from repo # and save it as array of hashes in st_diffs db field - def reload_diffs + def save_diffs new_attributes = {} new_diffs = [] if commits.size.zero? new_attributes[:state] = :empty else - diff_collection = unmerged_diffs + diff_collection = compare.diffs(Commit.max_diff_options) if diff_collection.overflow? # Set our state to 'overflow' to make the #empty? and #collected? @@ -188,32 +254,17 @@ class MergeRequestDiff < ActiveRecord::Base end new_attributes[:st_diffs] = new_diffs - - new_attributes[:start_commit_sha] = self.target_branch_sha - new_attributes[:head_commit_sha] = self.source_branch_sha - new_attributes[:base_commit_sha] = branch_base_sha - update_columns_serialized(new_attributes) - - keep_around_commits - end - - def project - merge_request.target_project end def repository project.repository end - def branch_base_commit - return unless self.source_branch_sha && self.target_branch_sha - - project.merge_base_commit(self.source_branch_sha, self.target_branch_sha) - end + def find_base_sha + return unless head_commit_sha && start_commit_sha - def branch_base_sha - branch_base_commit.try(:sha) + project.merge_base_commit(head_commit_sha, start_commit_sha).try(:sha) end def utf8_st_diffs @@ -248,8 +299,8 @@ class MergeRequestDiff < ActiveRecord::Base end def keep_around_commits - repository.keep_around(target_branch_sha) - repository.keep_around(source_branch_sha) - repository.keep_around(branch_base_sha) + repository.keep_around(start_commit_sha) + repository.keep_around(head_commit_sha) + repository.keep_around(base_commit_sha) end end diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb new file mode 100644 index 00000000000..ab597c37947 --- /dev/null +++ b/app/models/merge_requests_closing_issues.rb @@ -0,0 +1,7 @@ +class MergeRequestsClosingIssues < ActiveRecord::Base + belongs_to :merge_request + belongs_to :issue + + validates :merge_request_id, uniqueness: { scope: :issue_id }, presence: true + validates :issue_id, presence: true +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 2bd7f198030..23aecbfa3a6 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -6,12 +6,16 @@ class Milestone < ActiveRecord::Base Any = MilestoneStruct.new('Any Milestone', '', -1) Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) + include CacheMarkdownField include InternalId include Sortable include Referable include StripAttribute include Milestoneish + cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :description + belongs_to :project has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues @@ -158,7 +162,7 @@ class Milestone < ActiveRecord::Base end def title=(value) - write_attribute(:title, Sanitize.clean(value.to_s)) if value.present? + write_attribute(:title, sanitize_title(value)) if value.present? end # Sorts the issues for the given IDs. @@ -204,4 +208,8 @@ class Milestone < ActiveRecord::Base iid end end + + def sanitize_title(value) + CGI.unescape_html(Sanitize.clean(value.to_s)) + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 8b52cc824cd..b67049f0f55 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -1,7 +1,12 @@ class Namespace < ActiveRecord::Base + acts_as_paranoid + + include CacheMarkdownField include Sortable include Gitlab::ShellAdapter + cache_markdown_field :description, pipeline: :description + has_many :projects, dependent: :destroy belongs_to :owner, class_name: "User" @@ -56,15 +61,13 @@ class Namespace < ActiveRecord::Base def clean_path(path) path = path.dup # Get the email username by removing everything after an `@` sign. - path.gsub!(/@.*\z/, "") - # Usernames can't end in .git, so remove it. - path.gsub!(/\.git\z/, "") - # Remove dashes at the start of the username. - path.gsub!(/\A-+/, "") - # Remove periods at the end of the username. - path.gsub!(/\.+\z/, "") + path.gsub!(/@.*\z/, "") # Remove everything that's not in the list of allowed characters. - path.gsub!(/[^a-zA-Z0-9_\-\.]/, "") + path.gsub!(/[^a-zA-Z0-9_\-\.]/, "") + # Remove trailing violations ('.atom', '.git', or '.') + path.gsub!(/(\.atom|\.git|\.)*\z/, "") + # Remove leading violations ('-') + path.gsub!(/\A\-+/, "") # Users with the great usernames of "." or ".." would end up with a blank username. # Work around that by setting their username to "blank", followed by a counter. @@ -139,6 +142,11 @@ class Namespace < ActiveRecord::Base projects.joins(:forked_project_link).find_by('forked_project_links.forked_from_project_id = ?', project.id) end + def lfs_enabled? + # User namespace will always default to the global setting + Gitlab.config.lfs.enabled + end + private def repository_storage_paths diff --git a/app/models/note.rb b/app/models/note.rb index b6b2ac6aa42..2d644b03e4d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -5,10 +5,14 @@ class Note < ActiveRecord::Base include Mentionable include Awardable include Importable + include FasterCacheKeys + include CacheMarkdownField + + cache_markdown_field :note, pipeline: :note # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. - attr_accessor :note_html + attr_accessor :redacted_note_html # An Array containing the number of visible references as generated by # Banzai::ObjectRenderer @@ -24,6 +28,9 @@ class Note < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" + # Only used by DiffNote, but defined here so that it can be used in `Note.includes` + belongs_to :resolved_by, class_name: "User" + has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy @@ -58,7 +65,7 @@ class Note < ActiveRecord::Base scope :fresh, ->{ order(created_at: :asc, id: :asc) } scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author, ->{ includes(:author) } - scope :inc_author_project_award_emoji, ->{ includes(:project, :author, :award_emoji) } + scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) } scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) } scope :non_diff_notes, ->{ where(type: ['Note', nil]) } @@ -69,7 +76,9 @@ class Note < ActiveRecord::Base project: [:project_members, { group: [:group_members] }]) end + after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code + before_validation :set_discussion_id after_save :keep_around_commit class << self @@ -81,13 +90,18 @@ class Note < ActiveRecord::Base [:discussion, noteable_type.try(:underscore), noteable_id].join("-") end + def discussion_id(*args) + Digest::SHA1.hexdigest(build_discussion_id(*args)) + end + def discussions Discussion.for_notes(all) end def grouped_diff_discussions - notes = diff_notes.fresh.select(&:active?) - Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h + active_notes = diff_notes.fresh.select(&:active?) + Discussion.for_diff_notes(active_notes). + map { |d| [d.line_code, d] }.to_h end # Searches for notes matching the given query. @@ -128,13 +142,16 @@ class Note < ActiveRecord::Base true end - def discussion_id - @discussion_id ||= - if for_merge_request? - [:discussion, :note, id].join("-") - else - self.class.build_discussion_id(noteable_type, noteable_id || commit_id) - end + def resolvable? + false + end + + def resolved? + false + end + + def to_be_resolved? + resolvable? && !resolved? end def max_attachment_size @@ -242,4 +259,28 @@ class Note < ActiveRecord::Base def nullify_blank_line_code self.line_code = nil if self.line_code.blank? end + + def ensure_discussion_id + return unless self.persisted? + # Needed in case the SELECT statement doesn't ask for `discussion_id` + return unless self.has_attribute?(:discussion_id) + return if self.discussion_id + + set_discussion_id + update_column(:discussion_id, self.discussion_id) + end + + def set_discussion_id + self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id) + end + + def build_discussion_id + if for_merge_request? + # Notes on merge requests are always in a discussion of their own, + # so we generate a unique discussion ID. + [:discussion, :note, SecureRandom.hex].join("-") + else + self.class.build_discussion_id(noteable_type, noteable_id || commit_id) + end + end end diff --git a/app/models/project.rb b/app/models/project.rb index 83b848ded8b..ea0daa47424 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,29 +6,34 @@ class Project < ActiveRecord::Base include Gitlab::VisibilityLevel include Gitlab::CurrentSettings include AccessRequestable + include CacheMarkdownField include Referable include Sortable include AfterCommitQueue include CaseSensitivity include TokenAuthenticatable + include ProjectFeaturesCompatibility extend Gitlab::ConfigHelper + class BoardLimitExceeded < StandardError; end + + NUMBER_OF_PERMITTED_BOARDS = 1 UNKNOWN_IMPORT_URL = 'http://unknown.git' + cache_markdown_field :description, pipeline: :description + + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, to: :project_feature, allow_nil: true + default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level - default_value_for :issues_enabled, gitlab_config_features.issues - default_value_for :merge_requests_enabled, gitlab_config_features.merge_requests - default_value_for :builds_enabled, gitlab_config_features.builds - default_value_for :wiki_enabled, gitlab_config_features.wiki - default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :container_registry_enabled, gitlab_config_features.container_registry default_value_for(:repository_storage) { current_application_settings.repository_storage } default_value_for(:shared_runners_enabled) { current_application_settings.shared_runners_enabled } after_create :ensure_dir_exist after_save :ensure_dir_exist, if: :namespace_id_changed? + after_initialize :setup_project_feature # set last_activity_at to the same as created_at after_create :set_last_activity_at @@ -59,10 +64,11 @@ class Project < ActiveRecord::Base # Relations belongs_to :creator, foreign_key: 'creator_id', class_name: 'User' - belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id' + belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' belongs_to :namespace has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id' + has_many :boards, before_add: :validate_board_limit, dependent: :destroy # Project services has_many :services @@ -128,6 +134,7 @@ class Project < ActiveRecord::Base has_many :notification_settings, dependent: :destroy, as: :source has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" + has_one :project_feature, dependent: :destroy has_many :commit_statuses, dependent: :destroy, class_name: 'CommitStatus', foreign_key: :gl_project_id has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline', foreign_key: :gl_project_id @@ -140,9 +147,11 @@ class Project < ActiveRecord::Base has_many :deployments, dependent: :destroy accepts_nested_attributes_for :variables, allow_destroy: true + accepts_nested_attributes_for :project_feature delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true + delegate :add_user, to: :team # Validations validates :creator, presence: true, on: :create @@ -157,8 +166,6 @@ class Project < ActiveRecord::Base length: { within: 0..255 }, format: { with: Gitlab::Regex.project_path_regex, message: Gitlab::Regex.project_path_regex_message } - validates :issues_enabled, :merge_requests_enabled, - :wiki_enabled, inclusion: { in: [true, false] } validates :namespace, presence: true validates_uniqueness_of :name, scope: :namespace_id validates_uniqueness_of :path, scope: :namespace_id @@ -194,9 +201,14 @@ class Project < ActiveRecord::Base scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } scope :with_push, -> { joins(:events).where('events.action = ?', Event::PUSHED) } + scope :with_builds_enabled, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id').where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0') } + scope :with_issues_enabled, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id').where('project_features.issues_access_level IS NULL or project_features.issues_access_level > 0') } + scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') } scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } + scope :excluding_project, ->(project) { where.not(id: project) } + state_machine :import_status, initial: :none do event :import_start do transition [:none, :finished] => :started @@ -365,26 +377,24 @@ class Project < ActiveRecord::Base %r{(?<project>#{name_pattern}/#{name_pattern})} end - def trending(since = 1.month.ago) - # By counting in the JOIN we don't expose the GROUP BY to the outer query. - # This means that calls such as "any?" and "count" just return a number of - # the total count, instead of the counts grouped per project as a Hash. - join_body = "INNER JOIN ( - SELECT project_id, COUNT(*) AS amount - FROM notes - WHERE created_at >= #{sanitize(since)} - GROUP BY project_id - ) join_note_counts ON projects.id = join_note_counts.project_id" - - joins(join_body).reorder('join_note_counts.amount DESC') + def trending + joins('INNER JOIN trending_projects ON projects.id = trending_projects.project_id'). + reorder('trending_projects.id ASC') end - # Deletes gitlab project export files older than 24 hours - def remove_gitlab_exports! - Gitlab::Popen.popen(%W(find #{Gitlab::ImportExport.storage_path} -not -path #{Gitlab::ImportExport.storage_path} -mmin +1440 -delete)) + def cached_count + Rails.cache.fetch('total_project_count', expires_in: 5.minutes) do + Project.count + end end end + def lfs_enabled? + return namespace.lfs_enabled? if self[:lfs_enabled].nil? + + self[:lfs_enabled] && Gitlab.config.lfs.enabled + end + def repository_storage_path Gitlab.config.repositories.storages[repository_storage] end @@ -431,7 +441,7 @@ class Project < ActiveRecord::Base # ref can't be HEAD, can only be branch/tag name or SHA def latest_successful_builds_for(ref = default_branch) - latest_pipeline = pipelines.latest_successful_for(ref).first + latest_pipeline = pipelines.latest_successful_for(ref) if latest_pipeline latest_pipeline.builds.latest.with_artifacts @@ -466,8 +476,6 @@ class Project < ActiveRecord::Base end def reset_cache_and_import_attrs - update(import_error: nil) - ProjectCacheWorker.perform_async(self.id) self.import_data.destroy if self.import_data @@ -606,7 +614,10 @@ class Project < ActiveRecord::Base end def new_issue_address(author) - if Gitlab::IncomingEmail.enabled? && author + # This feature is disabled for the time being. + return nil + + if Gitlab::IncomingEmail.enabled? && author # rubocop:disable Lint/UnreachableCode Gitlab::IncomingEmail.reply_address( "#{path_with_namespace}+#{author.authentication_token}") end @@ -674,6 +685,10 @@ class Project < ActiveRecord::Base update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) end + def has_wiki? + wiki_enabled? || has_external_wiki? + end + def external_wiki if has_external_wiki.nil? cache_has_external_wiki # Populate @@ -814,11 +829,6 @@ class Project < ActiveRecord::Base end end - def update_merge_requests(oldrev, newrev, ref, user) - MergeRequests::RefreshService.new(self, user). - execute(oldrev, newrev, ref) - end - def valid_repo? repository.exists? rescue @@ -870,10 +880,16 @@ class Project < ActiveRecord::Base # Check if current branch name is marked as protected in the system def protected_branch?(branch_name) + return true if empty_repo? && default_branch_protected? + @protected_branches ||= self.protected_branches.to_a ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present? end + def user_can_push_to_empty_repo?(user) + !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER + end + def forked? !(forked_project_link.nil? || forked_project_link.forked_from_project.nil?) end @@ -1019,6 +1035,7 @@ class Project < ActiveRecord::Base "refs/heads/#{branch}", force: true) repository.copy_gitattributes(branch) + repository.expire_avatar_cache(branch) reload_default_branch end @@ -1079,16 +1096,21 @@ class Project < ActiveRecord::Base !namespace.share_with_group_lock end - def pipeline(sha, ref) + def pipeline_for(ref, sha = nil) + sha ||= commit(ref).try(:sha) + + return unless sha + pipelines.order(id: :desc).find_by(sha: sha, ref: ref) end - def ensure_pipeline(sha, ref, current_user = nil) - pipeline(sha, ref) || pipelines.create(sha: sha, ref: ref, user: current_user) + def ensure_pipeline(ref, sha, current_user = nil) + pipeline_for(ref, sha) || + pipelines.create(sha: sha, ref: ref, user: current_user) end def enable_ci - self.builds_enabled = true + project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) end def any_runners?(&block) @@ -1103,12 +1125,6 @@ class Project < ActiveRecord::Base self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end - # TODO (ayufan): For now we use runners_token (backward compatibility) - # In 8.4 every build will have its own individual token valid for time of build - def valid_build_token?(token) - self.builds_enabled? && self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) - end - def build_coverage_enabled? build_coverage_regex.present? end @@ -1154,16 +1170,6 @@ class Project < ActiveRecord::Base @wiki ||= ProjectWiki.new(self, self.owner) end - def schedule_delete!(user_id, params) - # Queue this task for after the commit, so once we mark pending_delete it will run - run_after_commit do - job_id = ProjectDestroyWorker.perform_async(id, user_id, params) - Rails.logger.info("User #{user_id} scheduled destruction of project #{path_with_namespace} with job ID #{job_id}") - end - - update_attribute(:pending_delete, true) - end - def running_or_pending_build_count(force: false) Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do builds.running_or_pending.count(:all) @@ -1263,8 +1269,50 @@ class Project < ActiveRecord::Base end end + def pushes_since_gc + Gitlab::Redis.with { |redis| redis.get(pushes_since_gc_redis_key).to_i } + end + + def increment_pushes_since_gc + Gitlab::Redis.with { |redis| redis.incr(pushes_since_gc_redis_key) } + end + + def reset_pushes_since_gc + Gitlab::Redis.with { |redis| redis.del(pushes_since_gc_redis_key) } + end + + def environments_for(ref, commit, with_tags: false) + environment_ids = deployments.group(:environment_id). + select(:environment_id) + + environment_ids = + if with_tags + environment_ids.where('ref=? OR tag IS TRUE', ref) + else + environment_ids.where(ref: ref) + end + + environments.where(id: environment_ids).select do |environment| + environment.includes_commit?(commit) + end + end + private + def pushes_since_gc_redis_key + "projects/#{id}/pushes_since_gc" + end + + # Prevents the creation of project_feature record for every project + def setup_project_feature + build_project_feature unless project_feature + end + + def default_branch_protected? + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE + end + def authorized_for_user_by_group?(user, min_access_level) member = user.group_members.find_by(source_id: group) @@ -1288,4 +1336,8 @@ class Project < ActiveRecord::Base shared_projects.any? end + + def validate_board_limit(board) + raise BoardLimitExceeded, 'Number of permitted boards exceeded' if boards.size >= NUMBER_OF_PERMITTED_BOARDS + end end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb new file mode 100644 index 00000000000..530f7d5a30e --- /dev/null +++ b/app/models/project_feature.rb @@ -0,0 +1,72 @@ +class ProjectFeature < ActiveRecord::Base + # == Project features permissions + # + # Grants access level to project tools + # + # Tools can be enabled only for users, everyone or disabled + # Access control is made only for non private projects + # + # levels: + # + # Disabled: not enabled for anyone + # Private: enabled only for team members + # Enabled: enabled for everyone able to access the project + # + + # Permision levels + DISABLED = 0 + PRIVATE = 10 + ENABLED = 20 + + FEATURES = %i(issues merge_requests wiki snippets builds) + + # Default scopes force us to unscope here since a service may need to check + # permissions for a project in pending_delete + # http://stackoverflow.com/questions/1540645/how-to-disable-default-scope-for-a-belongs-to + belongs_to :project, -> { unscope(where: :pending_delete) } + + default_value_for :builds_access_level, value: ENABLED, allows_nil: false + default_value_for :issues_access_level, value: ENABLED, allows_nil: false + default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false + default_value_for :snippets_access_level, value: ENABLED, allows_nil: false + default_value_for :wiki_access_level, value: ENABLED, allows_nil: false + + def feature_available?(feature, user) + raise ArgumentError, 'invalid project feature' unless FEATURES.include?(feature) + + get_permission(user, public_send("#{feature}_access_level")) + end + + def builds_enabled? + return true unless builds_access_level + + builds_access_level > DISABLED + end + + def wiki_enabled? + return true unless wiki_access_level + + wiki_access_level > DISABLED + end + + def merge_requests_enabled? + return true unless merge_requests_access_level + + merge_requests_access_level > DISABLED + end + + private + + def get_permission(user, level) + case level + when DISABLED + false + when PRIVATE + user && (project.team.member?(user) || user.admin?) + when ENABLED + true + else + true + end + end +end diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index e52a6bd7c84..db46def11eb 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -1,4 +1,6 @@ class ProjectGroupLink < ActiveRecord::Base + include Expirable + GUEST = 10 REPORTER = 20 DEVELOPER = 30 @@ -8,7 +10,7 @@ class ProjectGroupLink < ActiveRecord::Base belongs_to :group validates :project_id, presence: true - validates :group_id, presence: true + validates :group, presence: true validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" } validates :group_access, presence: true validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true @@ -26,7 +28,7 @@ class ProjectGroupLink < ActiveRecord::Base self.class.access_options.key(self.group_access) end - private + private def different_group if self.group && self.project && self.project.group == self.group diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index 5e166471077..fa66e5864b8 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -51,8 +51,7 @@ class BuildsEmailService < Service end def test_data(project = nil, user = nil) - build = project.builds.last - Gitlab::BuildDataBuilder.build(build) + Gitlab::DataBuilder::Build.build(project.builds.last) end def fields diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb index 511b2eac792..5af93860d09 100644 --- a/app/models/project_services/campfire_service.rb +++ b/app/models/project_services/campfire_service.rb @@ -1,4 +1,6 @@ class CampfireService < Service + include HTTParty + prop_accessor :token, :subdomain, :room validates :token, presence: true, if: :activated? @@ -29,18 +31,53 @@ class CampfireService < Service def execute(data) return unless supported_events.include?(data[:object_kind]) - room = gate.find_room_by_name(self.room) - return true unless room - + self.class.base_uri base_uri message = build_message(data) - - room.speak(message) + speak(self.room, message, auth) end private - def gate - @gate ||= Tinder::Campfire.new(subdomain, token: token) + def base_uri + @base_uri ||= "https://#{subdomain}.campfirenow.com" + end + + def auth + # use a dummy password, as explained in the Campfire API doc: + # https://github.com/basecamp/campfire-api#authentication + @auth ||= { + basic_auth: { + username: token, + password: 'X' + } + } + end + + # Post a message into a room, returns the message Hash in case of success. + # Returns nil otherwise. + # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message + def speak(room_name, message, auth) + room = rooms(auth).find { |r| r["name"] == room_name } + return nil unless room + + path = "/room/#{room["id"]}/speak.json" + body = { + body: { + message: { + type: 'TextMessage', + body: message + } + } + } + res = self.class.post(path, auth.merge(body)) + res.code == 201 ? res : nil + end + + # Returns a list of rooms, or []. + # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms + def rooms(auth) + res = self.class.get("/rooms.json", auth) + res.code == 200 ? res["rooms"] : [] end def build_message(push) diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index 63a5ed14484..d9fba3d4a41 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -9,6 +9,10 @@ class CustomIssueTrackerService < IssueTrackerService end end + def title=(value) + self.properties['title'] = value if self.properties + end + def description if self.properties && self.properties['description'].present? self.properties['description'] diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index d7c986c1a91..afebd3b6a12 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -39,7 +39,7 @@ class HipchatService < Service end def supported_events - %w(push issue merge_request note tag_push build) + %w(push issue confidential_issue merge_request note tag_push build) end def execute(data) diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index ad19b7795da..5301f9fa0ff 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -1,7 +1,9 @@ class PivotaltrackerService < Service include HTTParty - prop_accessor :token + API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' + + prop_accessor :token, :restrict_to_branch validates :token, presence: true, if: :activated? def title @@ -18,7 +20,17 @@ class PivotaltrackerService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' } + { + type: 'text', + name: 'token', + placeholder: 'Pivotal Tracker API token.' + }, + { + type: 'text', + name: 'restrict_to_branch', + placeholder: 'Comma-separated list of branches which will be ' \ + 'automatically inspected. Leave blank to include all branches.' + } ] end @@ -28,8 +40,8 @@ class PivotaltrackerService < Service def execute(data) return unless supported_events.include?(data[:object_kind]) + return unless allowed_branch?(data[:ref]) - url = 'https://www.pivotaltracker.com/services/v5/source_commits' data[:commits].each do |commit| message = { 'source_commit' => { @@ -40,7 +52,7 @@ class PivotaltrackerService < Service } } PivotaltrackerService.post( - url, + API_ENDPOINT, body: message.to_json, headers: { 'Content-Type' => 'application/json', @@ -49,4 +61,15 @@ class PivotaltrackerService < Service ) end end + + private + + def allowed_branch?(ref) + return true unless ref.present? && restrict_to_branch.present? + + branch = Gitlab::Git.ref_name(ref) + allowed_branches = restrict_to_branch.split(',').map(&:strip) + + branch.present? && allowed_branches.include?(branch) + end end diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb index abbc780dc1a..e1b937817f4 100644 --- a/app/models/project_services/slack_service.rb +++ b/app/models/project_services/slack_service.rb @@ -1,6 +1,6 @@ class SlackService < Service prop_accessor :webhook, :username, :channel - boolean_accessor :notify_only_broken_builds + boolean_accessor :notify_only_broken_builds, :notify_only_broken_pipelines validates :webhook, presence: true, url: true, if: :activated? def initialize_properties @@ -10,6 +10,7 @@ class SlackService < Service if properties.nil? self.properties = {} self.notify_only_broken_builds = true + self.notify_only_broken_pipelines = true end end @@ -38,13 +39,15 @@ class SlackService < Service { type: 'text', name: 'username', placeholder: 'username' }, { type: 'text', name: 'channel', placeholder: "#general" }, { type: 'checkbox', name: 'notify_only_broken_builds' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] default_fields + build_event_channels end def supported_events - %w(push issue merge_request note tag_push build wiki_page) + %w[push issue confidential_issue merge_request note tag_push + build pipeline wiki_page] end def execute(data) @@ -62,32 +65,22 @@ class SlackService < Service # 'close' action. Ignore update events for now to prevent duplicate # messages from arriving. - message = \ - case object_kind - when "push", "tag_push" - PushMessage.new(data) - when "issue" - IssueMessage.new(data) unless is_update?(data) - when "merge_request" - MergeMessage.new(data) unless is_update?(data) - when "note" - NoteMessage.new(data) - when "build" - BuildMessage.new(data) if should_build_be_notified?(data) - when "wiki_page" - WikiPageMessage.new(data) - end - - opt = {} - - event_channel = get_channel_field(object_kind) || channel - - opt[:channel] = event_channel if event_channel - opt[:username] = username if username + message = get_message(object_kind, data) if message + opt = {} + + event_channel = get_channel_field(object_kind) || channel + + opt[:channel] = event_channel if event_channel + opt[:username] = username if username + notifier = Slack::Notifier.new(webhook, opt) notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback) + + true + else + false end end @@ -105,6 +98,25 @@ class SlackService < Service private + def get_message(object_kind, data) + case object_kind + when "push", "tag_push" + PushMessage.new(data) + when "issue" + IssueMessage.new(data) unless is_update?(data) + when "merge_request" + MergeMessage.new(data) unless is_update?(data) + when "note" + NoteMessage.new(data) + when "build" + BuildMessage.new(data) if should_build_be_notified?(data) + when "pipeline" + PipelineMessage.new(data) if should_pipeline_be_notified?(data) + when "wiki_page" + WikiPageMessage.new(data) + end + end + def get_channel_field(event) field_name = event_channel_name(event) self.public_send(field_name) @@ -142,6 +154,17 @@ class SlackService < Service false end end + + def should_pipeline_be_notified?(data) + case data[:object_attributes][:status] + when 'success' + !notify_only_broken_pipelines? + when 'failed' + true + else + false + end + end end require "slack_service/issue_message" @@ -149,4 +172,5 @@ require "slack_service/push_message" require "slack_service/merge_message" require "slack_service/note_message" require "slack_service/build_message" +require "slack_service/pipeline_message" require "slack_service/wiki_page_message" diff --git a/app/models/project_services/slack_service/build_message.rb b/app/models/project_services/slack_service/build_message.rb index 69c21b3fc38..0fca4267bad 100644 --- a/app/models/project_services/slack_service/build_message.rb +++ b/app/models/project_services/slack_service/build_message.rb @@ -9,7 +9,7 @@ class SlackService attr_reader :user_name attr_reader :duration - def initialize(params, commit = true) + def initialize(params) @sha = params[:sha] @ref_type = params[:tag] ? 'tag' : 'branch' @ref = params[:ref] @@ -36,7 +36,7 @@ class SlackService def message "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}" - end + end def format(string) Slack::Notifier::LinkFormatter.format(string) diff --git a/app/models/project_services/slack_service/issue_message.rb b/app/models/project_services/slack_service/issue_message.rb index 88e053ec192..cd87a79d0c6 100644 --- a/app/models/project_services/slack_service/issue_message.rb +++ b/app/models/project_services/slack_service/issue_message.rb @@ -11,7 +11,7 @@ class SlackService attr_reader :description def initialize(params) - @user_name = params[:user][:name] + @user_name = params[:user][:username] @project_name = params[:project_name] @project_url = params[:project_url] diff --git a/app/models/project_services/slack_service/merge_message.rb b/app/models/project_services/slack_service/merge_message.rb index 11fc691022b..b7615c96068 100644 --- a/app/models/project_services/slack_service/merge_message.rb +++ b/app/models/project_services/slack_service/merge_message.rb @@ -10,7 +10,7 @@ class SlackService attr_reader :title def initialize(params) - @user_name = params[:user][:name] + @user_name = params[:user][:username] @project_name = params[:project_name] @project_url = params[:project_url] diff --git a/app/models/project_services/slack_service/note_message.rb b/app/models/project_services/slack_service/note_message.rb index 89ba51cb662..9e84e90f38c 100644 --- a/app/models/project_services/slack_service/note_message.rb +++ b/app/models/project_services/slack_service/note_message.rb @@ -10,7 +10,7 @@ class SlackService def initialize(params) params = HashWithIndifferentAccess.new(params) - @user_name = params[:user][:name] + @user_name = params[:user][:username] @project_name = params[:project_name] @project_url = params[:project_url] diff --git a/app/models/project_services/slack_service/pipeline_message.rb b/app/models/project_services/slack_service/pipeline_message.rb new file mode 100644 index 00000000000..f06b3562965 --- /dev/null +++ b/app/models/project_services/slack_service/pipeline_message.rb @@ -0,0 +1,79 @@ +class SlackService + class PipelineMessage < BaseMessage + attr_reader :sha, :ref_type, :ref, :status, :project_name, :project_url, + :user_name, :duration, :pipeline_id + + def initialize(data) + pipeline_attributes = data[:object_attributes] + @sha = pipeline_attributes[:sha] + @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' + @ref = pipeline_attributes[:ref] + @status = pipeline_attributes[:status] + @duration = pipeline_attributes[:duration] + @pipeline_id = pipeline_attributes[:id] + + @project_name = data[:project][:path_with_namespace] + @project_url = data[:project][:web_url] + @user_name = data[:commit] && data[:commit][:author_name] + end + + def pretext + '' + end + + def fallback + format(message) + end + + def attachments + [{ text: format(message), color: attachment_color }] + end + + private + + def message + "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}" + end + + def format(string) + Slack::Notifier::LinkFormatter.format(string) + end + + def humanized_status + case status + when 'success' + 'passed' + else + status + end + end + + def attachment_color + if status == 'success' + 'good' + else + 'danger' + end + end + + def branch_url + "#{project_url}/commits/#{ref}" + end + + def branch_link + "[#{ref}](#{branch_url})" + end + + def project_link + "[#{project_name}](#{project_url})" + end + + def pipeline_url + "#{project_url}/pipelines/#{pipeline_id}" + end + + def pipeline_link + "[#{Commit.truncate_sha(sha)}](#{pipeline_url})" + end + end +end diff --git a/app/models/project_services/slack_service/wiki_page_message.rb b/app/models/project_services/slack_service/wiki_page_message.rb index f336d9e7691..160ca3ac115 100644 --- a/app/models/project_services/slack_service/wiki_page_message.rb +++ b/app/models/project_services/slack_service/wiki_page_message.rb @@ -9,7 +9,7 @@ class SlackService attr_reader :description def initialize(params) - @user_name = params[:user][:name] + @user_name = params[:user][:username] @project_name = params[:project_name] @project_url = params[:project_url] diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 19fd082534c..79d041d2775 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -15,9 +15,9 @@ class ProjectTeam users, access, current_user = *args if users.respond_to?(:each) - add_users(users, access, current_user) + add_users(users, access, current_user: current_user) else - add_user(users, access, current_user) + add_user(users, access, current_user: current_user) end end @@ -33,17 +33,24 @@ class ProjectTeam member end - def add_users(users, access, current_user = nil) - ProjectMember.add_users_into_projects( + def add_users(users, access_level, current_user: nil, expires_at: nil) + ProjectMember.add_users_to_projects( [project.id], users, - access, - current_user + access_level, + current_user: current_user, + expires_at: expires_at ) end - def add_user(user, access, current_user = nil) - add_users([user], access, current_user) + def add_user(user, access_level, current_user: nil, expires_at: nil) + ProjectMember.add_user( + project, + user, + access_level, + current_user: current_user, + expires_at: expires_at + ) end # Remove all users from project team @@ -162,7 +169,7 @@ class ProjectTeam # Each group produces a list of maximum access level per user. We take the # max of the values produced by each group. - if project.invited_groups.any? && project.allowed_to_share_with_group? + if project_shared_with_group? project.project_group_links.each do |group_link| invited_access = max_invited_level_for_users(group_link, user_ids) merge_max!(access, invited_access) @@ -199,43 +206,17 @@ class ProjectTeam def fetch_members(level = nil) project_members = project.members group_members = group ? group.members : [] - invited_members = [] - - if project.invited_groups.any? && project.allowed_to_share_with_group? - project.project_group_links.includes(group: [:group_members]).each do |group_link| - invited_group = group_link.group - im = invited_group.members - - if level - int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] - - # Skip group members if we ask for masters - # but max group access is developers - next if int_level > group_link.group_access - - # If we ask for developers and max - # group access is developers we need to provide - # both group master, developers as devs - if int_level == group_link.group_access - im.where("access_level >= ?)", group_link.group_access) - else - im.send(level) - end - end - - invited_members << im - end - - invited_members = invited_members.flatten.compact - end if level - project_members = project_members.send(level) - group_members = group_members.send(level) if group + project_members = project_members.public_send(level) + group_members = group_members.public_send(level) if group end user_ids = project_members.pluck(:user_id) + + invited_members = fetch_invited_members(level) user_ids.push(*invited_members.map(&:user_id)) if invited_members.any? + user_ids.push(*group_members.pluck(:user_id)) if group User.where(id: user_ids) @@ -248,4 +229,38 @@ class ProjectTeam def merge_max!(first_hash, second_hash) first_hash.merge!(second_hash) { |_key, old, new| old > new ? old : new } end + + def project_shared_with_group? + project.invited_groups.any? && project.allowed_to_share_with_group? + end + + def fetch_invited_members(level = nil) + invited_members = [] + + return invited_members unless project_shared_with_group? + + project.project_group_links.includes(group: [:group_members]).each do |link| + invited_group_members = link.group.members + + if level + numeric_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] + + # If we're asked for a level that's higher than the group's access, + # there's nothing left to do + next if numeric_level > link.group_access + + # Make sure we include everyone _above_ the requested level as well + invited_group_members = + if numeric_level == link.group_access + invited_group_members.where("access_level >= ?", link.group_access) + else + invited_group_members.public_send(level) + end + end + + invited_members << invited_group_members + end + + invited_members.flatten.compact + end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index a255710f577..46f70da2452 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -56,6 +56,10 @@ class ProjectWiki end end + def repository_exists? + !!repository.exists? + end + def empty? pages.empty? end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 226b3f54342..6240912a6e1 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,11 +5,14 @@ class ProtectedBranch < ActiveRecord::Base validates :name, presence: true validates :project, presence: true - has_one :merge_access_level, dependent: :destroy - has_one :push_access_level, dependent: :destroy + has_many :merge_access_levels, dependent: :destroy + has_many :push_access_levels, dependent: :destroy - accepts_nested_attributes_for :push_access_level - accepts_nested_attributes_for :merge_access_level + validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + + accepts_nested_attributes_for :push_access_levels + accepts_nested_attributes_for :merge_access_levels def commit project.commit(self.name) diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index b1112ee737d..806b3ccd275 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -17,8 +19,4 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 6a5e49cf453..92e9c51d883 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -20,8 +22,4 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/release.rb b/app/models/release.rb index e196b84eb18..c936899799e 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -1,4 +1,8 @@ class Release < ActiveRecord::Base + include CacheMarkdownField + + cache_markdown_field :description + belongs_to :project validates :description, :project, :tag, presence: true diff --git a/app/models/repository.rb b/app/models/repository.rb index c1170c470ea..72e473871fa 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -111,8 +111,10 @@ class Repository def find_commits_by_message(query, ref = nil, path = nil, limit = 1000, offset = 0) ref ||= root_ref - # Limited to 1000 commits for now, could be parameterized? - args = %W(#{Gitlab.config.git.bin_path} log #{ref} --pretty=%H --skip #{offset} --max-count #{limit} --grep=#{query}) + args = %W( + #{Gitlab.config.git.bin_path} log #{ref} --pretty=%H --skip #{offset} + --max-count #{limit} --grep=#{query} --regexp-ignore-case + ) args = args.concat(%W(-- #{path})) if path.present? git_log_results = Gitlab::Popen.popen(args, path_to_repo).first.lines.map(&:chomp) @@ -120,8 +122,21 @@ class Repository commits end - def find_branch(name) - raw_repository.branches.find { |branch| branch.name == name } + def find_branch(name, fresh_repo: true) + # Since the Repository object may have in-memory index changes, invalidating the memoized Repository object may + # cause unintended side effects. Because finding a branch is a read-only operation, we can safely instantiate + # a new repo here to ensure a consistent state to avoid a libgit2 bug where concurrent access (e.g. via git gc) + # may cause the branch to "disappear" erroneously or have the wrong SHA. + # + # See: https://github.com/libgit2/libgit2/issues/1534 and https://gitlab.com/gitlab-org/gitlab-ce/issues/15392 + raw_repo = + if fresh_repo + Gitlab::Git::Repository.new(path_to_repo) + else + raw_repository + end + + raw_repo.find_branch(name) end def find_tag(name) @@ -136,7 +151,7 @@ class Repository return false unless target GitHooksService.new.execute(user, path_to_repo, oldrev, target, ref) do - rugged.branches.create(branch_name, target) + update_ref!(ref, target, oldrev) end after_create_branch @@ -168,7 +183,7 @@ class Repository ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name GitHooksService.new.execute(user, path_to_repo, oldrev, newrev, ref) do - rugged.branches.delete(branch_name) + update_ref!(ref, newrev, oldrev) end after_remove_branch @@ -202,6 +217,21 @@ class Repository rugged.references.exist?(ref) end + def update_ref!(name, newrev, oldrev) + # We use 'git update-ref' because libgit2/rugged currently does not + # offer 'compare and swap' ref updates. Without compare-and-swap we can + # (and have!) accidentally reset the ref to an earlier state, clobbering + # commits. See also https://github.com/libgit2/libgit2/issues/1534. + command = %w[git update-ref --stdin -z] + _, status = Gitlab::Popen.popen(command, path_to_repo) do |stdin| + stdin.write("update #{name}\x00#{newrev}\x00#{oldrev}\x00") + end + + return if status.zero? + + raise CommitError.new("Could not update branch #{name.sub('refs/heads/', '')}. Please refresh and try again.") + end + # Makes sure a commit is kept around when Git garbage collection runs. # Git GC will delete commits from the repository that are no longer in any # branches or tags, but we want to keep some of these commits around, for @@ -277,7 +307,7 @@ class Repository def cache_keys %i(size commit_count readme version contribution_guide changelog - license_blob license_key gitignore) + license_blob license_key gitignore koding_yml) end # Keys for data on branch/tag operations. @@ -391,6 +421,8 @@ class Repository expire_exists_cache expire_root_ref_cache expire_emptiness_caches + + repository_event(:create_repository) end # Runs code just before a repository is deleted. @@ -407,6 +439,8 @@ class Repository expire_root_ref_cache expire_emptiness_caches expire_exists_cache + + repository_event(:remove_repository) end # Runs code just before the HEAD of a repository is changed. @@ -414,6 +448,8 @@ class Repository # Cached divergent commit counts are based on repository head expire_branch_cache expire_root_ref_cache + + repository_event(:change_default_branch) end # Runs code before pushing (= creating or removing) a tag. @@ -421,12 +457,16 @@ class Repository expire_cache expire_tags_cache expire_tag_count_cache + + repository_event(:push_tag) end # Runs code before removing a tag. def before_remove_tag expire_tags_cache expire_tag_count_cache + + repository_event(:remove_tag) end def before_import @@ -443,6 +483,8 @@ class Repository # Runs code after a new commit has been pushed. def after_push_commit(branch_name, revision) expire_cache(branch_name, revision) + + repository_event(:push_commit, branch: branch_name) end # Runs code after a new branch has been created. @@ -450,11 +492,15 @@ class Repository expire_branches_cache expire_has_visible_content_cache expire_branch_count_cache + + repository_event(:push_branch) end # Runs code before removing an existing branch. def before_remove_branch expire_branches_cache + + repository_event(:remove_branch) end # Runs code after an existing branch has been removed. @@ -537,6 +583,14 @@ class Repository end end + def koding_yml + return nil unless head_exists? + + cache.fetch(:koding_yml) do + file_on_head(/\A\.koding\.yml\z/) + end + end + def gitlab_ci_yml return nil unless head_exists? @@ -601,7 +655,7 @@ class Repository commit(sha) end - def next_branch(name, opts={}) + def next_branch(name, opts = {}) branch_ids = self.branch_names.map do |n| next 1 if n == name result = n.match(/\A#{name}-([0-9]+)\z/) @@ -636,9 +690,7 @@ class Repository def tags_sorted_by(value) case value when 'name' - # Would be better to use `sort_by` but `version_sorter` only exposes - # `sort` and `rsort` - VersionSorter.rsort(tag_names).map { |tag_name| find_tag(tag_name) } + VersionSorter.rsort(tags) { |tag| tag.name } when 'updated_desc' tags_sorted_by_committed_date.reverse when 'updated_asc' @@ -667,6 +719,14 @@ class Repository end end + def ref_name_for_sha(ref_path, sha) + args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha}) + + # Not found -> ["", 0] + # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0] + Gitlab::Popen.popen(args, path_to_repo).first.split.last + end + def refs_contains_sha(ref_type, sha) args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha}) names = Gitlab::Popen.popen(args, path_to_repo).first @@ -706,64 +766,61 @@ class Repository @root_ref ||= cache.fetch(:root_ref) { raw_repository.root_ref } end - def commit_dir(user, path, message, branch) - commit_with_hooks(user, branch) do |ref| - committer = user_to_committer(user) - options = {} - options[:committer] = committer - options[:author] = committer - - options[:commit] = { - message: message, - branch: ref, - update_ref: false, + def commit_dir(user, path, message, branch, author_email: nil, author_name: nil) + update_branch_with_hooks(user, branch) do |ref| + options = { + commit: { + branch: ref, + message: message, + update_ref: false + } } + options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) + raw_repository.mkdir(path, options) end end - def commit_file(user, path, content, message, branch, update) - commit_with_hooks(user, branch) do |ref| - committer = user_to_committer(user) - options = {} - options[:committer] = committer - options[:author] = committer - options[:commit] = { - message: message, - branch: ref, - update_ref: false, + def commit_file(user, path, content, message, branch, update, author_email: nil, author_name: nil) + update_branch_with_hooks(user, branch) do |ref| + options = { + commit: { + branch: ref, + message: message, + update_ref: false + }, + file: { + content: content, + path: path, + update: update + } } - options[:file] = { - content: content, - path: path, - update: update - } + options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) Gitlab::Git::Blob.commit(raw_repository, options) end end - def update_file(user, path, content, branch:, previous_path:, message:) - commit_with_hooks(user, branch) do |ref| - committer = user_to_committer(user) - options = {} - options[:committer] = committer - options[:author] = committer - options[:commit] = { - message: message, - branch: ref, - update_ref: false + def update_file(user, path, content, branch:, previous_path:, message:, author_email: nil, author_name: nil) + update_branch_with_hooks(user, branch) do |ref| + options = { + commit: { + branch: ref, + message: message, + update_ref: false + }, + file: { + content: content, + path: path, + update: true + } } - options[:file] = { - content: content, - path: path, - update: true - } + options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) - if previous_path + if previous_path && previous_path != path options[:file][:previous_path] = previous_path Gitlab::Git::Blob.rename(raw_repository, options) else @@ -772,34 +829,85 @@ class Repository end end - def remove_file(user, path, message, branch) - commit_with_hooks(user, branch) do |ref| - committer = user_to_committer(user) - options = {} - options[:committer] = committer - options[:author] = committer - options[:commit] = { - message: message, - branch: ref, - update_ref: false, + def remove_file(user, path, message, branch, author_email: nil, author_name: nil) + update_branch_with_hooks(user, branch) do |ref| + options = { + commit: { + branch: ref, + message: message, + update_ref: false + }, + file: { + path: path + } } - options[:file] = { - path: path - } + options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) Gitlab::Git::Blob.remove(raw_repository, options) end end - def user_to_committer(user) + def multi_action(user:, branch:, message:, actions:, author_email: nil, author_name: nil) + update_branch_with_hooks(user, branch) do |ref| + index = rugged.index + parents = [] + branch = find_branch(ref) + + if branch + last_commit = branch.target + index.read_tree(last_commit.raw_commit.tree) + parents = [last_commit.sha] + end + + actions.each do |action| + case action[:action] + when :create, :update, :move + mode = + case action[:action] + when :update + index.get(action[:file_path])[:mode] + when :move + index.get(action[:previous_path])[:mode] + end + mode ||= 0o100644 + + index.remove(action[:previous_path]) if action[:action] == :move + + content = action[:encoding] == 'base64' ? Base64.decode64(action[:content]) : action[:content] + oid = rugged.write(content, :blob) + + index.add(path: action[:file_path], oid: oid, mode: mode) + when :delete + index.remove(action[:file_path]) + end + end + + options = { + tree: index.write_tree(rugged), + message: message, + parents: parents + } + options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) + + Rugged::Commit.create(rugged, options) + end + end + + def get_committer_and_author(user, email: nil, name: nil) + committer = user_to_committer(user) + author = Gitlab::Git::committer_hash(email: email, name: name) || committer + { - email: user.email, - name: user.name, - time: Time.now + author: author, + committer: committer } end + def user_to_committer(user) + Gitlab::Git::committer_hash(email: user.email, name: user.name) + end + def can_be_merged?(source_sha, target_branch) our_commit = rugged.branches[target_branch].target their_commit = rugged.lookup(source_sha) @@ -821,7 +929,7 @@ class Repository merge_index = rugged.merge_commits(our_commit, their_commit) return false if merge_index.conflicts? - commit_with_hooks(user, merge_request.target_branch) do + update_branch_with_hooks(user, merge_request.target_branch) do actual_options = options.merge( parents: [our_commit, their_commit], tree: merge_index.write_tree(rugged), @@ -839,7 +947,7 @@ class Repository return false unless revert_tree_id - commit_with_hooks(user, base_branch) do + update_branch_with_hooks(user, base_branch) do committer = user_to_committer(user) source_sha = Rugged::Commit.create(rugged, message: commit.revert_message, @@ -856,7 +964,7 @@ class Repository return false unless cherry_pick_tree_id - commit_with_hooks(user, base_branch) do + update_branch_with_hooks(user, base_branch) do committer = user_to_committer(user) source_sha = Rugged::Commit.create(rugged, message: commit.message, @@ -871,6 +979,14 @@ class Repository end end + def resolve_conflicts(user, branch, params) + update_branch_with_hooks(user, branch) do + committer = user_to_committer(user) + + Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + end + end + def check_revert_content(commit, base_branch) source_sha = find_branch(base_branch).target.sha args = [commit.id, source_sha] @@ -908,7 +1024,8 @@ class Repository root_ref_commit = commit(root_ref) if branch_commit - is_ancestor?(branch_commit.id, root_ref_commit.id) + same_head = branch_commit.id == root_ref_commit.id + !same_head && is_ancestor?(branch_commit.id, root_ref_commit.id) else nil end @@ -932,54 +1049,22 @@ class Repository Gitlab::Popen.popen(args, path_to_repo).first.scrub.split(/^--$/) end - def parse_search_result(result) - ref = nil - filename = nil - basename = nil - startline = 0 - - result.each_line.each_with_index do |line, index| - if line =~ /^.*:.*:\d+:/ - ref, filename, startline = line.split(':') - startline = startline.to_i - index - extname = Regexp.escape(File.extname(filename)) - basename = filename.sub(/#{extname}$/, '') - break - end - end - - data = "" - - result.each_line do |line| - data << line.sub(ref, '').sub(filename, '').sub(/^:-\d+-/, '').sub(/^::\d+:/, '') - end - - OpenStruct.new( - filename: filename, - basename: basename, - ref: ref, - startline: startline, - data: data - ) - end - def fetch_ref(source_path, source_ref, target_ref) args = %W(#{Gitlab.config.git.bin_path} fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref}) Gitlab::Popen.popen(args, path_to_repo) end - def commit_with_hooks(current_user, branch) + def create_ref(ref, ref_path) + fetch_ref(path_to_repo, ref, ref_path) + end + + def update_branch_with_hooks(current_user, branch) update_autocrlf_option - oldrev = Gitlab::Git::BLANK_SHA ref = Gitlab::Git::BRANCH_REF_PREFIX + branch target_branch = find_branch(branch) was_empty = empty? - if !was_empty && target_branch - oldrev = target_branch.target.id - end - # Make commit newrev = yield(ref) @@ -987,24 +1072,19 @@ class Repository raise CommitError.new('Failed to create commit') end + if rugged.lookup(newrev).parent_ids.empty? || target_branch.nil? + oldrev = Gitlab::Git::BLANK_SHA + else + oldrev = rugged.merge_base(newrev, target_branch.target.sha) + end + GitHooksService.new.execute(current_user, path_to_repo, oldrev, newrev, ref) do - if was_empty || !target_branch - # Create branch - rugged.references.create(ref, newrev) + update_ref!(ref, newrev, oldrev) + if was_empty || !target_branch # If repo was empty expire cache after_create if was_empty after_create_branch - else - # Update head - current_head = find_branch(branch).target.id - - # Make sure target branch was not changed during pre-receive hook - if current_head == oldrev - rugged.references.update(ref, newrev) - else - raise CommitError.new('Commit was rejected because branch received new push') - end end end @@ -1035,7 +1115,7 @@ class Repository @avatar ||= cache.fetch(:avatar) do AVATAR_FILES.find do |file| - blob_at_branch('master', file) + blob_at_branch(root_ref, file) end end end @@ -1061,4 +1141,8 @@ class Repository def keep_around_ref_name(sha) "refs/keep-around/#{sha}" end + + def repository_event(event, tags = {}) + Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) + end end diff --git a/app/models/service.rb b/app/models/service.rb index 40cd9b861f0..66c804f2b06 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -7,10 +7,12 @@ class Service < ActiveRecord::Base default_value_for :active, false default_value_for :push_events, true default_value_for :issues_events, true + default_value_for :confidential_issues_events, true default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true default_value_for :build_events, true + default_value_for :pipeline_events, true default_value_for :wiki_page_events, true after_initialize :initialize_properties @@ -33,9 +35,11 @@ class Service < ActiveRecord::Base scope :push_hooks, -> { where(push_events: true, active: true) } scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) } scope :issue_hooks, -> { where(issues_events: true, active: true) } + scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } scope :build_hooks, -> { where(build_events: true, active: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } @@ -79,13 +83,17 @@ class Service < ActiveRecord::Base end def test_data(project, user) - Gitlab::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end def event_channel_names [] end + def event_names + supported_events.map { |event| "#{event}_events" } + end + def event_field(event) nil end @@ -95,7 +103,7 @@ class Service < ActiveRecord::Base end def supported_events - %w(push tag_push issue merge_request wiki_page) + %w(push tag_push issue confidential_issue merge_request wiki_page) end def execute(data) @@ -128,6 +136,7 @@ class Service < ActiveRecord::Base end def #{arg}=(value) + self.properties ||= {} updated_properties['#{arg}'] = #{arg} unless #{arg}_changed? self.properties['#{arg}'] = value end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 5ec933601ac..2373b445009 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -1,9 +1,20 @@ class Snippet < ActiveRecord::Base include Gitlab::VisibilityLevel include Linguist::BlobHelper + include CacheMarkdownField include Participable include Referable include Sortable + include Awardable + + cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :content + + # If file_name changes, it invalidates content + alias_method :default_content_html_invalidator, :content_html_invalidated? + def content_html_invalidated? + default_content_html_invalidator || file_name_changed? + end default_value_for :visibility_level, Snippet::PRIVATE diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 12df68ef83b..3b8b9833565 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -7,4 +7,8 @@ class SpamLog < ActiveRecord::Base user.block user.destroy end + + def text + [title, description].join("\n") + end end diff --git a/app/models/spam_report.rb b/app/models/spam_report.rb deleted file mode 100644 index cdc7321b08e..00000000000 --- a/app/models/spam_report.rb +++ /dev/null @@ -1,5 +0,0 @@ -class SpamReport < ActiveRecord::Base - belongs_to :user - - validates :user, presence: true -end diff --git a/app/models/todo.rb b/app/models/todo.rb index 8d7a5965aa1..6ae9956ade5 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -1,4 +1,6 @@ class Todo < ActiveRecord::Base + include Sortable + ASSIGNED = 1 MENTIONED = 2 BUILD_FAILED = 3 @@ -41,6 +43,23 @@ class Todo < ActiveRecord::Base after_save :keep_around_commit + class << self + def sort(method) + method == "priority" ? order_by_labels_priority : order_by(method) + end + + # Order by priority depending on which issue/merge request the Todo belongs to + # Todos with highest priority first then oldest todos + # Need to order by created_at last because of differences on Mysql and Postgres when joining by type "Merge_request/Issue" + def order_by_labels_priority + highest_priority = highest_label_priority(["Issue", "MergeRequest"], "todos.target_id").to_sql + + select("#{table_name}.*, (#{highest_priority}) AS highest_priority"). + order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')). + order('todos.created_at') + end + end + def build_failed? action == BUILD_FAILED end diff --git a/app/models/trending_project.rb b/app/models/trending_project.rb new file mode 100644 index 00000000000..27e3732da17 --- /dev/null +++ b/app/models/trending_project.rb @@ -0,0 +1,35 @@ +class TrendingProject < ActiveRecord::Base + belongs_to :project + + # The number of months to include in the trending calculation. + MONTHS_TO_INCLUDE = 1 + + # The maximum number of projects to include in the trending set. + PROJECTS_LIMIT = 100 + + # Populates the trending projects table with the current list of trending + # projects. + def self.refresh! + # The calculation **must** run in a transaction. If the removal of data and + # insertion of new data were to run separately a user might end up with an + # empty list of trending projects for a short period of time. + transaction do + delete_all + + timestamp = connection.quote(MONTHS_TO_INCLUDE.months.ago) + + connection.execute <<-EOF.strip_heredoc + INSERT INTO #{table_name} (project_id) + SELECT project_id + FROM notes + INNER JOIN projects ON projects.id = notes.project_id + WHERE notes.created_at >= #{timestamp} + AND notes.system IS FALSE + AND projects.visibility_level = #{Gitlab::VisibilityLevel::PUBLIC} + GROUP BY project_id + ORDER BY count(*) DESC + LIMIT #{PROJECTS_LIMIT}; + EOF + end + end +end diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 00b19686d48..808acec098f 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -3,18 +3,19 @@ class U2fRegistration < ActiveRecord::Base belongs_to :user - def self.register(user, app_id, json_response, challenges) + def self.register(user, app_id, params, challenges) u2f = U2F::U2F.new(app_id) registration = self.new begin - response = U2F::RegisterResponse.load_from_json(json_response) + response = U2F::RegisterResponse.load_from_json(params[:device_response]) registration_data = u2f.register!(challenges, response) registration.update(certificate: registration_data.certificate, key_handle: registration_data.key_handle, public_key: registration_data.public_key, counter: registration_data.counter, - user: user) + user: user, + name: params[:name]) rescue JSON::ParserError, NoMethodError, ArgumentError registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.') rescue U2F::Error => e diff --git a/app/models/user.rb b/app/models/user.rb index db747434959..f367f4616fb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,13 +23,13 @@ class User < ActiveRecord::Base default_value_for :theme_id, gitlab_config.default_theme attr_encrypted :otp_secret, - key: Gitlab::Application.config.secret_key_base, + key: Gitlab::Application.secrets.otp_key_base, mode: :per_attribute_iv_and_salt, insecure_mode: true, algorithm: 'aes-256-cbc' devise :two_factor_authenticatable, - otp_secret_encryption_key: Gitlab::Application.config.secret_key_base + otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base devise :two_factor_backupable, otp_number_of_backup_codes: 10 serialize :otp_backup_codes, JSON @@ -279,6 +279,11 @@ class User < ActiveRecord::Base find_by('users.username = ? OR users.id = ?', name_or_id.to_s, name_or_id.to_i) end + # Returns a user for the given SSH key. + def find_by_ssh_key_id(key_id) + find_by(id: Key.unscoped.select(:user_id).where(id: key_id)) + end + def build_user(attrs = {}) User.new(attrs) end @@ -429,6 +434,13 @@ class User < ActiveRecord::Base owned_groups.select(:id), namespace.id).joins(:namespace) end + # Returns projects which user can admin issues on (for example to move an issue to that project). + # + # This logic is duplicated from `Ability#project_abilities` into a SQL form. + def projects_where_can_admin_issues + authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled + end + def is_admin? admin end @@ -453,16 +465,12 @@ class User < ActiveRecord::Base can?(:create_group, nil) end - def abilities - Ability.abilities - end - def can_select_namespace? several_namespaces? || admin end def can?(action, subject) - abilities.allowed?(self, action, subject) + Ability.allowed?(self, action, subject) end def first_name @@ -482,10 +490,10 @@ class User < ActiveRecord::Base (personal_projects.count.to_f / projects_limit) * 100 end - def recent_push(project_id = nil) + def recent_push(project_ids = nil) # Get push events not earlier than 2 hours ago events = recent_events.code_push.where("created_at > ?", Time.now - 2.hours) - events = events.where(project_id: project_id) if project_id + events = events.where(project_id: project_ids) if project_ids # Use the latest event that has not been pushed or merged recently events.recent.find do |event| @@ -581,6 +589,11 @@ class User < ActiveRecord::Base end def set_projects_limit + # `User.select(:id)` raises + # `ActiveModel::MissingAttributeError: missing attribute: projects_limit` + # without this safeguard! + return unless self.has_attribute?(:projects_limit) + connection_default_value_defined = new_record? && !projects_limit_changed? return unless self.projects_limit.nil? || connection_default_value_defined @@ -809,13 +822,13 @@ class User < ActiveRecord::Base def todos_done_count(force: false) Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do - todos.done.count + TodosFinder.new(self, state: :done).execute.count end end def todos_pending_count(force: false) Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do - todos.pending.count + TodosFinder.new(self, state: :pending).execute.count end end @@ -824,6 +837,22 @@ class User < ActiveRecord::Base todos_pending_count(force: true) end + # This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth + # flow means we don't call that automatically (and can't conveniently do so). + # + # See: + # <https://github.com/plataformatec/devise/blob/v4.0.0/lib/devise/models/lockable.rb#L92> + # + def increment_failed_attempts! + self.failed_attempts ||= 0 + self.failed_attempts += 1 + if attempts_exceeded? + lock_access! unless access_locked? + else + save(validate: false) + end + end + private def projects_union(min_access_level = nil) @@ -878,7 +907,7 @@ class User < ActiveRecord::Base if domain_matches?(allowed_domains, self.email) valid = true else - error = "is not whitelisted. Email domains valid for registration are: #{allowed_domains.join(', ')}" + error = "domain is not authorized for sign-up" valid = false end end diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb new file mode 100644 index 00000000000..0949c6ef083 --- /dev/null +++ b/app/models/user_agent_detail.rb @@ -0,0 +1,9 @@ +class UserAgentDetail < ActiveRecord::Base + belongs_to :subject, polymorphic: true + + validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true + + def submittable? + !submitted? + end +end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb new file mode 100644 index 00000000000..118c100ca11 --- /dev/null +++ b/app/policies/base_policy.rb @@ -0,0 +1,116 @@ +class BasePolicy + class RuleSet + attr_reader :can_set, :cannot_set + def initialize(can_set, cannot_set) + @can_set = can_set + @cannot_set = cannot_set + end + + def size + to_set.size + end + + def self.empty + new(Set.new, Set.new) + end + + def can?(ability) + @can_set.include?(ability) && !@cannot_set.include?(ability) + end + + def include?(ability) + can?(ability) + end + + def to_set + @can_set - @cannot_set + end + + def merge(other) + @can_set.merge(other.can_set) + @cannot_set.merge(other.cannot_set) + end + + def can!(*abilities) + @can_set.merge(abilities) + end + + def cannot!(*abilities) + @cannot_set.merge(abilities) + end + + def freeze + @can_set.freeze + @cannot_set.freeze + super + end + end + + def self.abilities(user, subject) + new(user, subject).abilities + end + + def self.class_for(subject) + return GlobalPolicy if subject.nil? + + subject.class.ancestors.each do |klass| + next unless klass.name + + begin + policy_class = "#{klass.name}Policy".constantize + + # NOTE: the < operator here tests whether policy_class + # inherits from BasePolicy + return policy_class if policy_class < BasePolicy + rescue NameError + nil + end + end + + raise "no policy for #{subject.class.name}" + end + + attr_reader :user, :subject + def initialize(user, subject) + @user = user + @subject = subject + end + + def abilities + return RuleSet.empty if @user && @user.blocked? + return anonymous_abilities if @user.nil? + collect_rules { rules } + end + + def anonymous_abilities + collect_rules { anonymous_rules } + end + + def anonymous_rules + rules + end + + def delegate!(new_subject) + @rule_set.merge(Ability.allowed(@user, new_subject)) + end + + def can?(rule) + @rule_set.can?(rule) + end + + def can!(*rules) + @rule_set.can!(*rules) + end + + def cannot!(*rules) + @rule_set.cannot!(*rules) + end + + private + + def collect_rules(&b) + @rule_set = RuleSet.empty + yield + @rule_set + end +end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb new file mode 100644 index 00000000000..2232e231cf8 --- /dev/null +++ b/app/policies/ci/build_policy.rb @@ -0,0 +1,13 @@ +module Ci + class BuildPolicy < CommitStatusPolicy + def rules + super + + # If we can't read build we should also not have that + # ability when looking at this in context of commit_status + %w(read create update admin).each do |rule| + cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build" + end + end + end +end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb new file mode 100644 index 00000000000..7edd383530d --- /dev/null +++ b/app/policies/ci/runner_policy.rb @@ -0,0 +1,13 @@ +module Ci + class RunnerPolicy < BasePolicy + def rules + return unless @user + + can! :assign_runner if @user.is_admin? + + return if @subject.is_shared? || @subject.locked? + + can! :assign_runner if @user.ci_authorized_runners.include?(@subject) + end + end +end diff --git a/app/policies/commit_status_policy.rb b/app/policies/commit_status_policy.rb new file mode 100644 index 00000000000..593df738328 --- /dev/null +++ b/app/policies/commit_status_policy.rb @@ -0,0 +1,5 @@ +class CommitStatusPolicy < BasePolicy + def rules + delegate! @subject.project + end +end diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb new file mode 100644 index 00000000000..163d070ff90 --- /dev/null +++ b/app/policies/deployment_policy.rb @@ -0,0 +1,5 @@ +class DeploymentPolicy < BasePolicy + def rules + delegate! @subject.project + end +end diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb new file mode 100644 index 00000000000..f4219569161 --- /dev/null +++ b/app/policies/environment_policy.rb @@ -0,0 +1,5 @@ +class EnvironmentPolicy < BasePolicy + def rules + delegate! @subject.project + end +end diff --git a/app/policies/external_issue_policy.rb b/app/policies/external_issue_policy.rb new file mode 100644 index 00000000000..d9e28bd107a --- /dev/null +++ b/app/policies/external_issue_policy.rb @@ -0,0 +1,5 @@ +class ExternalIssuePolicy < BasePolicy + def rules + delegate! @subject.project + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb new file mode 100644 index 00000000000..3c2fbe6b56b --- /dev/null +++ b/app/policies/global_policy.rb @@ -0,0 +1,8 @@ +class GlobalPolicy < BasePolicy + def rules + return unless @user + + can! :create_group if @user.can_create_group + can! :read_users_list + end +end diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb new file mode 100644 index 00000000000..62335527654 --- /dev/null +++ b/app/policies/group_member_policy.rb @@ -0,0 +1,19 @@ +class GroupMemberPolicy < BasePolicy + def rules + return unless @user + + target_user = @subject.user + group = @subject.group + + return if group.last_owner?(target_user) + + can_manage = Ability.allowed?(@user, :admin_group_member, group) + + if can_manage + can! :update_group_member + can! :destroy_group_member + elsif @user == target_user + can! :destroy_group_member + end + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb new file mode 100644 index 00000000000..97ff6233968 --- /dev/null +++ b/app/policies/group_policy.rb @@ -0,0 +1,45 @@ +class GroupPolicy < BasePolicy + def rules + can! :read_group if @subject.public? + return unless @user + + globally_viewable = @subject.public? || (@subject.internal? && !@user.external?) + member = @subject.users.include?(@user) + owner = @user.admin? || @subject.has_owner?(@user) + master = owner || @subject.has_master?(@user) + + can_read = false + can_read ||= globally_viewable + can_read ||= member + can_read ||= @user.admin? + can_read ||= GroupProjectsFinder.new(@subject).execute(@user).any? + can! :read_group if can_read + + # Only group masters and group owners can create new projects + if master + can! :create_projects + can! :admin_milestones + end + + # Only group owner and administrators can admin group + if owner + can! :admin_group + can! :admin_namespace + can! :admin_group_member + can! :change_visibility_level + end + + if globally_viewable && @subject.request_access_enabled && !member + can! :request_access + end + end + + def can_read_group? + return true if @subject.public? + return true if @user.admin? + return true if @subject.internal? && !@user.external? + return true if @subject.users.include?(@user) + + GroupProjectsFinder.new(@subject).execute(@user).any? + end +end diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb new file mode 100644 index 00000000000..c253f9a9399 --- /dev/null +++ b/app/policies/issuable_policy.rb @@ -0,0 +1,14 @@ +class IssuablePolicy < BasePolicy + def action_name + @subject.class.name.underscore + end + + def rules + if @user && (@subject.author == @user || @subject.assignee == @user) + can! :"read_#{action_name}" + can! :"update_#{action_name}" + end + + delegate! @subject.project + end +end diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb new file mode 100644 index 00000000000..bd1811a3c54 --- /dev/null +++ b/app/policies/issue_policy.rb @@ -0,0 +1,28 @@ +class IssuePolicy < IssuablePolicy + def issue + @subject + end + + def rules + super + + if @subject.confidential? && !can_read_confidential? + cannot! :read_issue + cannot! :admin_issue + cannot! :update_issue + cannot! :read_issue + end + end + + private + + def can_read_confidential? + return false unless @user + return true if @user.admin? + return true if @subject.author == @user + return true if @subject.assignee == @user + return true if @subject.project.team.member?(@user, Gitlab::Access::REPORTER) + + false + end +end diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb new file mode 100644 index 00000000000..bc3afc626fb --- /dev/null +++ b/app/policies/merge_request_policy.rb @@ -0,0 +1,3 @@ +class MergeRequestPolicy < IssuablePolicy + # pass +end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb new file mode 100644 index 00000000000..29bb357e00a --- /dev/null +++ b/app/policies/namespace_policy.rb @@ -0,0 +1,10 @@ +class NamespacePolicy < BasePolicy + def rules + return unless @user + + if @subject.owner == @user || @user.admin? + can! :create_projects + can! :admin_namespace + end + end +end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb new file mode 100644 index 00000000000..83847466ee2 --- /dev/null +++ b/app/policies/note_policy.rb @@ -0,0 +1,19 @@ +class NotePolicy < BasePolicy + def rules + delegate! @subject.project + + return unless @user + + if @subject.author == @user + can! :read_note + can! :update_note + can! :admin_note + can! :resolve_note + end + + if @subject.for_merge_request? && + @subject.noteable.author == @user + can! :resolve_note + end + end +end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb new file mode 100644 index 00000000000..46c5aa1a5be --- /dev/null +++ b/app/policies/personal_snippet_policy.rb @@ -0,0 +1,16 @@ +class PersonalSnippetPolicy < BasePolicy + def rules + can! :read_personal_snippet if @subject.public? + return unless @user + + if @subject.author == @user + can! :read_personal_snippet + can! :update_personal_snippet + can! :admin_personal_snippet + end + + if @subject.internal? && !@user.external? + can! :read_personal_snippet + end + end +end diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb new file mode 100644 index 00000000000..1c038dddd4b --- /dev/null +++ b/app/policies/project_member_policy.rb @@ -0,0 +1,22 @@ +class ProjectMemberPolicy < BasePolicy + def rules + # anonymous users have no abilities here + return unless @user + + target_user = @subject.user + project = @subject.project + + return if target_user == project.owner + + can_manage = Ability.allowed?(@user, :admin_project_member, project) + + if can_manage + can! :update_project_member + can! :destroy_project_member + end + + if @user == target_user + can! :destroy_project_member + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb new file mode 100644 index 00000000000..be4721d7a51 --- /dev/null +++ b/app/policies/project_policy.rb @@ -0,0 +1,242 @@ +class ProjectPolicy < BasePolicy + def rules + team_access!(user) + + owner = user.admin? || + project.owner == user || + (project.group && project.group.has_owner?(user)) + + owner_access! if owner + + if project.public? || (project.internal? && !user.external?) + guest_access! + public_access! + + # Allow to read builds for internal projects + can! :read_build if project.public_builds? + + if project.request_access_enabled && + !(owner || project.team.member?(user) || project_group_member?(user)) + can! :request_access + end + end + + archived_access! if project.archived? + + disabled_features! + end + + def project + @subject + end + + def guest_access! + can! :read_project + can! :read_board + can! :read_list + can! :read_wiki + can! :read_issue + can! :read_label + can! :read_milestone + can! :read_project_snippet + can! :read_project_member + can! :read_note + can! :create_project + can! :create_issue + can! :create_note + can! :upload_file + can! :read_cycle_analytics + end + + def reporter_access! + can! :download_code + can! :fork_project + can! :create_project_snippet + can! :update_issue + can! :admin_issue + can! :admin_label + can! :admin_list + can! :read_commit_status + can! :read_build + can! :read_container_image + can! :read_pipeline + can! :read_environment + can! :read_deployment + can! :read_merge_request + end + + # Permissions given when an user is team member of a project + def team_member_reporter_access! + can! :build_download_code + can! :build_read_container_image + end + + def developer_access! + can! :admin_merge_request + can! :update_merge_request + can! :create_commit_status + can! :update_commit_status + can! :create_build + can! :update_build + can! :create_pipeline + can! :update_pipeline + can! :create_merge_request + can! :create_wiki + can! :push_code + can! :resolve_note + can! :create_container_image + can! :update_container_image + can! :create_environment + can! :create_deployment + end + + def master_access! + can! :push_code_to_protected_branches + can! :update_project_snippet + can! :update_environment + can! :update_deployment + can! :admin_milestone + can! :admin_project_snippet + can! :admin_project_member + can! :admin_note + can! :admin_wiki + can! :admin_project + can! :admin_commit_status + can! :admin_build + can! :admin_container_image + can! :admin_pipeline + can! :admin_environment + can! :admin_deployment + end + + def public_access! + can! :download_code + can! :fork_project + can! :read_commit_status + can! :read_pipeline + can! :read_container_image + can! :build_download_code + can! :build_read_container_image + can! :read_merge_request + end + + def owner_access! + guest_access! + reporter_access! + developer_access! + master_access! + can! :change_namespace + can! :change_visibility_level + can! :rename_project + can! :remove_project + can! :archive_project + can! :remove_fork_project + can! :destroy_merge_request + can! :destroy_issue + end + + # Push abilities on the users team role + def team_access!(user) + access = project.team.max_member_access(user.id) + + return if access < Gitlab::Access::GUEST + guest_access! + + return if access < Gitlab::Access::REPORTER + reporter_access! + team_member_reporter_access! + + return if access < Gitlab::Access::DEVELOPER + developer_access! + + return if access < Gitlab::Access::MASTER + master_access! + end + + def archived_access! + cannot! :create_merge_request + cannot! :push_code + cannot! :push_code_to_protected_branches + cannot! :update_merge_request + cannot! :admin_merge_request + end + + def disabled_features! + unless project.feature_available?(:issues, user) + cannot!(*named_abilities(:issue)) + end + + unless project.feature_available?(:merge_requests, user) + cannot!(*named_abilities(:merge_request)) + end + + unless project.feature_available?(:issues, user) || project.feature_available?(:merge_requests, user) + cannot!(*named_abilities(:label)) + cannot!(*named_abilities(:milestone)) + end + + unless project.feature_available?(:snippets, user) + cannot!(*named_abilities(:project_snippet)) + end + + unless project.feature_available?(:wiki, user) || project.has_external_wiki? + cannot!(*named_abilities(:wiki)) + end + + unless project.feature_available?(:builds, user) + cannot!(*named_abilities(:build)) + cannot!(*named_abilities(:pipeline)) + cannot!(*named_abilities(:environment)) + cannot!(*named_abilities(:deployment)) + end + + unless project.container_registry_enabled + cannot!(*named_abilities(:container_image)) + end + end + + def anonymous_rules + return unless project.public? + + can! :read_project + can! :read_board + can! :read_list + can! :read_wiki + can! :read_label + can! :read_milestone + can! :read_project_snippet + can! :read_project_member + can! :read_merge_request + can! :read_note + can! :read_pipeline + can! :read_commit_status + can! :read_container_image + can! :download_code + can! :read_cycle_analytics + + # NOTE: may be overridden by IssuePolicy + can! :read_issue + + # Allow to read builds by anonymous user if guests are allowed + can! :read_build if project.public_builds? + + disabled_features! + end + + def project_group_member?(user) + project.group && + ( + project.group.members.exists?(user_id: user.id) || + project.group.requesters.exists?(user_id: user.id) + ) + end + + def named_abilities(name) + [ + :"read_#{name}", + :"create_#{name}", + :"update_#{name}", + :"admin_#{name}" + ] + end +end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb new file mode 100644 index 00000000000..57acccfafd9 --- /dev/null +++ b/app/policies/project_snippet_policy.rb @@ -0,0 +1,20 @@ +class ProjectSnippetPolicy < BasePolicy + def rules + can! :read_project_snippet if @subject.public? + return unless @user + + if @user && @subject.author == @user || @user.admin? + can! :read_project_snippet + can! :update_project_snippet + can! :admin_project_snippet + end + + if @subject.internal? && !@user.external? + can! :read_project_snippet + end + + if @subject.private? && @subject.project.team.member?(@user) + can! :read_project_snippet + end + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 00000000000..03a2499e263 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,11 @@ +class UserPolicy < BasePolicy + include Gitlab::CurrentSettings + + def rules + can! :read_user if @user || !restricted_public_level? + end + + def restricted_public_level? + current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) + end +end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb new file mode 100644 index 00000000000..76b9f1feda7 --- /dev/null +++ b/app/services/akismet_service.rb @@ -0,0 +1,68 @@ +class AkismetService + attr_accessor :owner, :text, :options + + def initialize(owner, text, options = {}) + @owner = owner + @text = text + @options = options + end + + def is_spam? + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + created_at: DateTime.now, + author: owner.name, + author_email: owner.email, + referrer: options[:referrer], + } + + begin + is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) + is_spam || is_blatant + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") + false + end + end + + def submit_ham + submit(:ham) + end + + def submit_spam + submit(:spam) + end + + private + + def akismet_client + @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, + Gitlab.config.gitlab.url) + end + + def akismet_enabled? + current_application_settings.akismet_enabled + end + + def submit(type) + return false unless akismet_enabled? + + params = { + type: 'comment', + text: text, + author: owner.name, + author_email: owner.email + } + + begin + akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end +end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 6072123b851..8ea88da8a53 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -4,11 +4,13 @@ module Auth AUDIENCE = 'container_registry' - def execute - return error('not found', 404) unless registry.enabled + def execute(authentication_abilities:) + @authentication_abilities = authentication_abilities + + return error('UNAVAILABLE', status: 404, message: 'registry not enabled') unless registry.enabled unless current_user || project - return error('forbidden', 403) unless scope + return error('DENIED', status: 403, message: 'access forbidden') unless scope end { token: authorized_token(scope).encoded } @@ -74,9 +76,9 @@ module Auth case requested_action when 'pull' - requested_project == project || can?(current_user, :read_container_image, requested_project) + requested_project.public? || build_can_pull?(requested_project) || user_can_pull?(requested_project) when 'push' - requested_project == project || can?(current_user, :create_container_image, requested_project) + build_can_push?(requested_project) || user_can_push?(requested_project) else false end @@ -85,5 +87,36 @@ module Auth def registry Gitlab.config.registry end + + def build_can_pull?(requested_project) + # Build can: + # 1. pull from its own project (for ex. a build) + # 2. read images from dependent projects if creator of build is a team member + @authentication_abilities.include?(:build_read_container_image) && + (requested_project == project || can?(current_user, :build_read_container_image, requested_project)) + end + + def user_can_pull?(requested_project) + @authentication_abilities.include?(:read_container_image) && + can?(current_user, :read_container_image, requested_project) + end + + def build_can_push?(requested_project) + # Build can push only to the project from which it originates + @authentication_abilities.include?(:build_create_container_image) && + requested_project == project + end + + def user_can_push?(requested_project) + @authentication_abilities.include?(:create_container_image) && + can?(current_user, :create_container_image, requested_project) + end + + def error(code, status:, message: '') + { + errors: [{ code: code, message: message }], + http_status: status + } + end end end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 0d55ba5a981..1a2bad77a02 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -7,12 +7,8 @@ class BaseService @project, @current_user, @params = project, user, params.dup end - def abilities - Ability.abilities - end - def can?(object, action, subject) - abilities.allowed?(object, action, subject) + Ability.allowed?(object, action, subject) end def notification_service @@ -60,9 +56,8 @@ class BaseService result end - def success - { - status: :success - } + def success(pass_back = {}) + pass_back[:status] = :success + pass_back end end diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb new file mode 100644 index 00000000000..9bdd7b6f0cf --- /dev/null +++ b/app/services/boards/create_service.rb @@ -0,0 +1,21 @@ +module Boards + class CreateService < BaseService + def execute + if project.boards.empty? + create_board! + else + project.boards.first + end + end + + private + + def create_board! + board = project.boards.create + board.lists.create(list_type: :backlog) + board.lists.create(list_type: :done) + + board + end + end +end diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb new file mode 100644 index 00000000000..c0d7ff5b585 --- /dev/null +++ b/app/services/boards/issues/create_service.rb @@ -0,0 +1,23 @@ +module Boards + module Issues + class CreateService < BaseService + def execute + create_issue(params.merge(label_ids: [list.label_id])) + end + + private + + def board + @board ||= project.boards.find(params.delete(:board_id)) + end + + def list + @list ||= board.lists.find(params.delete(:list_id)) + end + + def create_issue(params) + ::Issues::CreateService.new(project, current_user, params).execute + end + end + end +end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb new file mode 100644 index 00000000000..fd4a462c7b2 --- /dev/null +++ b/app/services/boards/issues/list_service.rb @@ -0,0 +1,67 @@ +module Boards + module Issues + class ListService < BaseService + def execute + issues = IssuesFinder.new(current_user, filter_params).execute + issues = without_board_labels(issues) unless list.movable? + issues = with_list_label(issues) if list.movable? + issues + end + + private + + def board + @board ||= project.boards.find(params[:board_id]) + end + + def list + @list ||= board.lists.find(params[:id]) + end + + def filter_params + set_default_scope + set_default_sort + set_project + set_state + + params + end + + def set_default_scope + params[:scope] = 'all' + end + + def set_default_sort + params[:sort] = 'priority' + end + + def set_project + params[:project_id] = project.id + end + + def set_state + params[:state] = list.done? ? 'closed' : 'opened' + end + + def board_label_ids + @board_label_ids ||= board.lists.movable.pluck(:label_id) + end + + def without_board_labels(issues) + return issues unless board_label_ids.any? + + issues.where.not( + LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id") + .where(label_id: board_label_ids).limit(1).arel.exists + ) + end + + def with_list_label(issues) + issues.where( + LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id") + .where("label_links.label_id = ?", list.label_id).limit(1).arel.exists + ) + end + end + end +end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb new file mode 100644 index 00000000000..96554a92a02 --- /dev/null +++ b/app/services/boards/issues/move_service.rb @@ -0,0 +1,63 @@ +module Boards + module Issues + class MoveService < BaseService + def execute(issue) + return false unless can?(current_user, :update_issue, issue) + return false unless valid_move? + + update_service.execute(issue) + end + + private + + def board + @board ||= project.boards.find(params[:board_id]) + end + + def valid_move? + moving_from_list.present? && moving_to_list.present? && + moving_from_list != moving_to_list + end + + def moving_from_list + @moving_from_list ||= board.lists.find_by(id: params[:from_list_id]) + end + + def moving_to_list + @moving_to_list ||= board.lists.find_by(id: params[:to_list_id]) + end + + def update_service + ::Issues::UpdateService.new(project, current_user, issue_params) + end + + def issue_params + { + add_label_ids: add_label_ids, + remove_label_ids: remove_label_ids, + state_event: issue_state + } + end + + def issue_state + return 'reopen' if moving_from_list.done? + return 'close' if moving_to_list.done? + end + + def add_label_ids + [moving_to_list.label_id].compact + end + + def remove_label_ids + label_ids = + if moving_to_list.movable? + moving_from_list.label_id + else + project.boards.joins(:lists).merge(List.movable).pluck(:label_id) + end + + Array(label_ids).compact + end + end + end +end diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb new file mode 100644 index 00000000000..84f1fc3a4e2 --- /dev/null +++ b/app/services/boards/list_service.rb @@ -0,0 +1,14 @@ +module Boards + class ListService < BaseService + def execute + create_board! if project.boards.empty? + project.boards + end + + private + + def create_board! + Boards::CreateService.new(project, current_user).execute + end + end +end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb new file mode 100644 index 00000000000..abc7aeece39 --- /dev/null +++ b/app/services/boards/lists/create_service.rb @@ -0,0 +1,25 @@ +module Boards + module Lists + class CreateService < BaseService + def execute(board) + List.transaction do + label = project.labels.find(params[:label_id]) + position = next_position(board) + + create_list(board, label, position) + end + end + + private + + def next_position(board) + max_position = board.lists.movable.maximum(:position) + max_position.nil? ? 0 : max_position.succ + end + + def create_list(board, label, position) + board.lists.create(label: label, list_type: :label, position: position) + end + end + end +end diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb new file mode 100644 index 00000000000..f986e05944c --- /dev/null +++ b/app/services/boards/lists/destroy_service.rb @@ -0,0 +1,29 @@ +module Boards + module Lists + class DestroyService < BaseService + def execute(list) + return false unless list.destroyable? + + @board = list.board + + list.with_lock do + decrement_higher_lists(list) + remove_list(list) + end + end + + private + + attr_reader :board + + def decrement_higher_lists(list) + board.lists.movable.where('position > ?', list.position) + .update_all('position = position - 1') + end + + def remove_list(list) + list.destroy + end + end + end +end diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb new file mode 100644 index 00000000000..d8048f1c67e --- /dev/null +++ b/app/services/boards/lists/generate_service.rb @@ -0,0 +1,34 @@ +module Boards + module Lists + class GenerateService < BaseService + def execute(board) + return false unless board.lists.movable.empty? + + List.transaction do + label_params.each { |params| create_list(board, params) } + end + + true + end + + private + + def create_list(board, params) + label = find_or_create_label(params) + Lists::CreateService.new(project, current_user, label_id: label.id).execute(board) + end + + def find_or_create_label(params) + project.labels.create_with(color: params[:color]) + .find_or_create_by(name: params[:name]) + end + + def label_params + [ + { name: 'To Do', color: '#F0AD4E' }, + { name: 'Doing', color: '#5CB85C' } + ] + end + end + end +end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb new file mode 100644 index 00000000000..c579ed4c869 --- /dev/null +++ b/app/services/boards/lists/list_service.rb @@ -0,0 +1,9 @@ +module Boards + module Lists + class ListService < BaseService + def execute(board) + board.lists + end + end + end +end diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb new file mode 100644 index 00000000000..f2a68865f7b --- /dev/null +++ b/app/services/boards/lists/move_service.rb @@ -0,0 +1,52 @@ +module Boards + module Lists + class MoveService < BaseService + def execute(list) + @board = list.board + @old_position = list.position + @new_position = params[:position] + + return false unless list.movable? + return false unless valid_move? + + list.with_lock do + reorder_intermediate_lists + update_list_position(list) + end + end + + private + + attr_reader :board, :old_position, :new_position + + def valid_move? + new_position.present? && new_position != old_position && + new_position >= 0 && new_position < board.lists.movable.size + end + + def reorder_intermediate_lists + if old_position < new_position + decrement_intermediate_lists + else + increment_intermediate_lists + end + end + + def decrement_intermediate_lists + board.lists.movable.where('position > ?', old_position) + .where('position <= ?', new_position) + .update_all('position = position - 1') + end + + def increment_intermediate_lists + board.lists.movable.where('position >= ?', new_position) + .where('position < ?', old_position) + .update_all('position = position + 1') + end + + def update_list_position(list) + list.update_attribute(:position, new_position) + end + end + end +end diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb deleted file mode 100644 index 4946f7076fd..00000000000 --- a/app/services/ci/create_builds_service.rb +++ /dev/null @@ -1,62 +0,0 @@ -module Ci - class CreateBuildsService - def initialize(pipeline) - @pipeline = pipeline - @config = pipeline.config_processor - end - - def execute(stage, user, status, trigger_request = nil) - builds_attrs = @config.builds_for_stage_and_ref(stage, @pipeline.ref, @pipeline.tag, trigger_request) - - # check when to create next build - builds_attrs = builds_attrs.select do |build_attrs| - case build_attrs[:when] - when 'on_success' - status == 'success' - when 'on_failure' - status == 'failed' - when 'always', 'manual' - %w(success failed).include?(status) - end - end - - # don't create the same build twice - builds_attrs.reject! do |build_attrs| - @pipeline.builds.find_by(ref: @pipeline.ref, - tag: @pipeline.tag, - trigger_request: trigger_request, - name: build_attrs[:name]) - end - - builds_attrs.map do |build_attrs| - build_attrs.slice!(:name, - :commands, - :tag_list, - :options, - :allow_failure, - :stage, - :stage_idx, - :environment, - :when, - :yaml_variables) - - build_attrs.merge!(pipeline: @pipeline, - ref: @pipeline.ref, - tag: @pipeline.tag, - trigger_request: trigger_request, - user: user, - project: @pipeline.project) - - # TODO: The proper implementation for this is in - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5295 - build_attrs[:status] = 'skipped' if build_attrs[:when] == 'manual' - - ## - # We do not persist new builds here. - # Those will be persisted when @pipeline is saved. - # - @pipeline.builds.new(build_attrs) - end - end - end -end diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb new file mode 100644 index 00000000000..005014fa1de --- /dev/null +++ b/app/services/ci/create_pipeline_builds_service.rb @@ -0,0 +1,42 @@ +module Ci + class CreatePipelineBuildsService < BaseService + attr_reader :pipeline + + def execute(pipeline) + @pipeline = pipeline + + new_builds.map do |build_attributes| + create_build(build_attributes) + end + end + + private + + def create_build(build_attributes) + build_attributes = build_attributes.merge( + pipeline: pipeline, + project: pipeline.project, + ref: pipeline.ref, + tag: pipeline.tag, + user: current_user, + trigger_request: trigger_request + ) + pipeline.builds.create(build_attributes) + end + + def new_builds + @new_builds ||= pipeline.config_builds_attributes. + reject { |build| existing_build_names.include?(build[:name]) } + end + + def existing_build_names + @existing_build_names ||= pipeline.builds.pluck(:name) + end + + def trigger_request + return @trigger_request if defined?(@trigger_request) + + @trigger_request ||= pipeline.trigger_requests.first + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index be91bf0db85..cde856b0186 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -1,49 +1,101 @@ module Ci class CreatePipelineService < BaseService - def execute - pipeline = project.pipelines.new(params) - pipeline.user = current_user + attr_reader :pipeline - unless ref_names.include?(params[:ref]) - pipeline.errors.add(:base, 'Reference not found') - return pipeline + def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil) + @pipeline = Ci::Pipeline.new( + project: project, + ref: ref, + sha: sha, + before_sha: before_sha, + tag: tag?, + trigger_requests: Array(trigger_request), + user: current_user + ) + + unless project.builds_enabled? + return error('Pipeline is disabled') end - if commit - pipeline.sha = commit.id - else - pipeline.errors.add(:base, 'Commit not found') - return pipeline + unless trigger_request || can?(current_user, :create_pipeline, project) + return error('Insufficient permissions to create a new pipeline') end - unless can?(current_user, :create_pipeline, project) - pipeline.errors.add(:base, 'Insufficient permissions to create a new pipeline') - return pipeline + unless branch? || tag? + return error('Reference not found') + end + + unless commit + return error('Commit not found') end unless pipeline.config_processor - pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') - return pipeline + unless pipeline.ci_yaml_file + return error('Missing .gitlab-ci.yml file') + end + return error(pipeline.yaml_errors, save: save_on_errors) end - pipeline.save! + if !ignore_skip_ci && skip_ci? + pipeline.skip if save_on_errors + return pipeline + end - unless pipeline.create_builds(current_user) - pipeline.errors.add(:base, 'No builds for this pipeline.') + unless pipeline.config_builds_attributes.present? + return error('No builds for this pipeline.') end pipeline.save + pipeline.process! pipeline end private - def ref_names - @ref_names ||= project.repository.ref_names + def skip_ci? + pipeline.git_commit_message =~ /\[(ci skip|skip ci)\]/i if pipeline.git_commit_message end def commit - @commit ||= project.commit(params[:ref]) + @commit ||= project.commit(origin_sha || origin_ref) + end + + def sha + commit.try(:id) + end + + def before_sha + params[:checkout_sha] || params[:before] || Gitlab::Git::BLANK_SHA + end + + def origin_sha + params[:checkout_sha] || params[:after] + end + + def origin_ref + params[:ref] + end + + def branch? + project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref) + end + + def tag? + project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref) + end + + def ref + Gitlab::Git.ref_name(origin_ref) + end + + def valid_sha? + origin_sha && origin_sha != Gitlab::Git::BLANK_SHA + end + + def error(message, save: false) + pipeline.errors.add(:base, message) + pipeline.drop if save + pipeline end end end diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 1e629cf119a..6af3c1ca5b1 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -1,20 +1,11 @@ module Ci class CreateTriggerRequestService def execute(project, trigger, ref, variables = nil) - commit = project.commit(ref) - return unless commit + trigger_request = trigger.trigger_requests.create(variables: variables) - # check if ref is tag - tag = project.repository.find_tag(ref).present? - - pipeline = project.pipelines.create(sha: commit.sha, ref: ref, tag: tag) - - trigger_request = trigger.trigger_requests.create!( - variables: variables, - pipeline: pipeline, - ) - - if pipeline.create_builds(nil, trigger_request) + pipeline = Ci::CreatePipelineService.new(project, nil, ref: ref). + execute(ignore_skip_ci: true, trigger_request: trigger_request) + if pipeline.persisted? trigger_request end end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb new file mode 100644 index 00000000000..d3dd30b2588 --- /dev/null +++ b/app/services/ci/process_pipeline_service.rb @@ -0,0 +1,81 @@ +module Ci + class ProcessPipelineService < BaseService + attr_reader :pipeline + + def execute(pipeline) + @pipeline = pipeline + + # This method will ensure that our pipeline does have all builds for all stages created + if created_builds.empty? + create_builds! + end + + @pipeline.with_lock do + new_builds = + stage_indexes_of_created_builds.map do |index| + process_stage(index) + end + + @pipeline.update_status + + # Return a flag if a when builds got enqueued + new_builds.flatten.any? + end + end + + private + + def create_builds! + Ci::CreatePipelineBuildsService.new(project, current_user).execute(pipeline) + end + + def process_stage(index) + current_status = status_for_prior_stages(index) + + created_builds_in_stage(index).select do |build| + if HasStatus::COMPLETED_STATUSES.include?(current_status) + process_build(build, current_status) + end + end + end + + def process_build(build, current_status) + if valid_statuses_for_when(build.when).include?(current_status) + build.enqueue + true + else + build.skip + false + end + end + + def valid_statuses_for_when(value) + case value + when 'on_success' + %w[success] + when 'on_failure' + %w[failed] + when 'always' + %w[success failed] + else + [] + end + end + + def status_for_prior_stages(index) + pipeline.builds.where('stage_idx < ?', index).latest.status || 'success' + end + + def stage_indexes_of_created_builds + created_builds.order(:stage_idx).pluck('distinct stage_idx') + end + + def created_builds_in_stage(index) + created_builds.where(stage_idx: index) + end + + def created_builds + pipeline.builds.created + end + end +end diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 9a187f5d694..6973191b203 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -8,16 +8,18 @@ module Ci builds = if current_runner.shared? builds. - # don't run projects which have not enabled shared runners - joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). + # don't run projects which have not enabled shared runners and builds + joins(:project).where(projects: { shared_runners_enabled: true }). + joins('LEFT JOIN project_features ON ci_builds.gl_project_id = project_features.project_id'). # this returns builds that are ordered by number of running builds # we prefer projects that don't use shared runners at all joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.gl_project_id=project_builds.gl_project_id"). + where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0'). order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') else # do run projects which are only assigned to this runner (FIFO) - builds.where(project: current_runner.projects.where(builds_enabled: true)).order('created_at ASC') + builds.where(project: current_runner.projects.with_builds_enabled).order('created_at ASC') end build = builds.find do |build| diff --git a/app/services/ci/web_hook_service.rb b/app/services/ci/web_hook_service.rb deleted file mode 100644 index 92e6df442b4..00000000000 --- a/app/services/ci/web_hook_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Ci - class WebHookService - def build_end(build) - execute_hooks(build.project, build_data(build)) - end - - def execute_hooks(project, data) - project.web_hooks.each do |web_hook| - async_execute_hook(web_hook, data) - end - end - - def async_execute_hook(hook, data) - Sidekiq::Client.enqueue(Ci::WebHookWorker, hook.id, data) - end - - def build_data(build) - project = build.project - data = {} - data.merge!({ - build_id: build.id, - build_name: build.name, - build_status: build.status, - build_started_at: build.started_at, - build_finished_at: build.finished_at, - project_id: project.id, - project_name: project.name, - gitlab_url: project.gitlab_url, - ref: build.ref, - before_sha: build.before_sha, - sha: build.sha, - }) - end - end -end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index ed73d8cb8c2..1c82599c579 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -16,11 +16,29 @@ module Commits error(ex.message) end + private + def commit raise NotImplementedError end - private + def commit_change(action) + raise NotImplementedError unless repository.respond_to?(action) + + into = @create_merge_request ? @commit.public_send("#{action}_branch_name") : @target_branch + tree_id = repository.public_send("check_#{action}_content", @commit, @target_branch) + + if tree_id + create_target_branch(into) if @create_merge_request + + repository.public_send(action, current_user, @commit, into, tree_id) + success + else + error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title} automatically. + It may have already been #{action.to_s.dasherize}, or a more recent commit may have updated some of its content." + raise ChangeError, error_msg + end + end def check_push_permissions allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@target_branch) diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb index f9a4efa7182..605cca36f9c 100644 --- a/app/services/commits/cherry_pick_service.rb +++ b/app/services/commits/cherry_pick_service.rb @@ -1,19 +1,7 @@ module Commits class CherryPickService < ChangeService def commit - cherry_pick_into = @create_merge_request ? @commit.cherry_pick_branch_name : @target_branch - cherry_pick_tree_id = repository.check_cherry_pick_content(@commit, @target_branch) - - if cherry_pick_tree_id - create_target_branch(cherry_pick_into) if @create_merge_request - - repository.cherry_pick(current_user, @commit, cherry_pick_into, cherry_pick_tree_id) - success - else - error_msg = "Sorry, we cannot cherry-pick this #{@commit.change_type_title} automatically. - It may have already been cherry-picked, or a more recent commit may have updated some of its content." - raise ChangeError, error_msg - end + commit_change(:cherry_pick) end end end diff --git a/app/services/commits/revert_service.rb b/app/services/commits/revert_service.rb index c7de9f6f35e..addd55cb32f 100644 --- a/app/services/commits/revert_service.rb +++ b/app/services/commits/revert_service.rb @@ -1,19 +1,7 @@ module Commits class RevertService < ChangeService def commit - revert_into = @create_merge_request ? @commit.revert_branch_name : @target_branch - revert_tree_id = repository.check_revert_content(@commit, @target_branch) - - if revert_tree_id - create_target_branch(revert_into) if @create_merge_request - - repository.revert(current_user, @commit, revert_into, revert_tree_id) - success - else - error_msg = "Sorry, we cannot revert this #{@commit.change_type_title} automatically. - It may have already been reverted, or a more recent commit may have updated some of its content." - raise ChangeError, error_msg - end + commit_change(:revert) end end end diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index 6d6075628af..5e8fafca98c 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -3,7 +3,7 @@ require 'securerandom' # Compare 2 branches for one repo or between repositories # and return Gitlab::Git::Compare object that responds to commits and diffs class CompareService - def execute(source_project, source_branch, target_project, target_branch) + def execute(source_project, source_branch, target_project, target_branch, straight: false) source_commit = source_project.commit(source_branch) return unless source_commit @@ -23,9 +23,10 @@ class CompareService raw_compare = Gitlab::Git::Compare.new( target_project.repository.raw_repository, target_branch, - source_sha + source_sha, + straight ) - Compare.new(raw_compare, target_project) + Compare.new(raw_compare, target_project, straight: straight) end end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb deleted file mode 100644 index 0b66b854dea..00000000000 --- a/app/services/create_commit_builds_service.rb +++ /dev/null @@ -1,69 +0,0 @@ -class CreateCommitBuildsService - def execute(project, user, params) - return unless project.builds_enabled? - - before_sha = params[:checkout_sha] || params[:before] - sha = params[:checkout_sha] || params[:after] - origin_ref = params[:ref] - - ref = Gitlab::Git.ref_name(origin_ref) - tag = Gitlab::Git.tag_ref?(origin_ref) - - # Skip branch removal - if sha == Gitlab::Git::BLANK_SHA - return false - end - - @pipeline = Ci::Pipeline.new( - project: project, - sha: sha, - ref: ref, - before_sha: before_sha, - tag: tag, - user: user) - - ## - # Skip creating pipeline if no gitlab-ci.yml is found - # - unless @pipeline.ci_yaml_file - return false - end - - ## - # Skip creating builds for commits that have [ci skip] - # but save pipeline object - # - if @pipeline.skip_ci? - return save_pipeline! - end - - ## - # Skip creating builds when CI config is invalid - # but save pipeline object - # - unless @pipeline.config_processor - return save_pipeline! - end - - ## - # Skip creating pipeline object if there are no builds for it. - # - unless @pipeline.create_builds(user) - @pipeline.errors.add(:base, 'No builds created') - return false - end - - save_pipeline! - end - - private - - ## - # Create a new pipeline and touch object to calculate status - # - def save_pipeline! - @pipeline.save! - @pipeline.touch - @pipeline - end -end diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index efeb9df9527..799ad3e1bd0 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -2,11 +2,9 @@ require_relative 'base_service' class CreateDeploymentService < BaseService def execute(deployable = nil) - environment = project.environments.find_or_create_by( - name: params[:environment] - ) + environment = find_or_create_environment - project.deployments.create( + deployment = project.deployments.create( environment: environment, ref: params[:ref], tag: params[:tag], @@ -14,5 +12,43 @@ class CreateDeploymentService < BaseService user: current_user, deployable: deployable ) + + deployment.update_merge_request_metrics! + + deployment + end + + private + + def find_or_create_environment + project.environments.find_or_create_by(name: expanded_name) do |environment| + environment.external_url = expanded_url + end + end + + def expanded_name + ExpandVariables.expand(name, variables) + end + + def expanded_url + return unless url + + @expanded_url ||= ExpandVariables.expand(url, variables) + end + + def name + params[:environment] + end + + def url + options[:url] + end + + def options + params[:options] || {} + end + + def variables + params[:variables] || [] end end diff --git a/app/services/create_spam_log_service.rb b/app/services/create_spam_log_service.rb deleted file mode 100644 index 59a66fde47a..00000000000 --- a/app/services/create_spam_log_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateSpamLogService < BaseService - def initialize(project, user, params) - super(project, user, params) - end - - def execute - spam_params = params.merge({ user_id: @current_user.id, - project_id: @project.id } ) - spam_log = SpamLog.new(spam_params) - spam_log.save - spam_log - end -end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 87f066edb6f..918eddaa53a 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -39,7 +39,12 @@ class DeleteBranchService < BaseService end def build_push_data(branch) - Gitlab::PushDataBuilder - .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + branch.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", + []) end end diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb index 32e0eed6b63..d0cb151a010 100644 --- a/app/services/delete_tag_service.rb +++ b/app/services/delete_tag_service.rb @@ -33,7 +33,12 @@ class DeleteTagService < BaseService end def build_push_data(tag) - Gitlab::PushDataBuilder - .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + tag.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", + []) end end diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb index ce79287e35a..eaff88d6463 100644 --- a/app/services/delete_user_service.rb +++ b/app/services/delete_user_service.rb @@ -18,9 +18,14 @@ class DeleteUserService user.personal_projects.each do |project| # Skip repository removal because we remove directory with namespace # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! + ::Projects::DestroyService.new(project, current_user, skip_repo: true).async_execute end - user.destroy + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + namespace = user.namespace + user_data = user.destroy + namespace.really_destroy! + + user_data end end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb index 3c42ac61be4..0081364b8aa 100644 --- a/app/services/destroy_group_service.rb +++ b/app/services/destroy_group_service.rb @@ -5,13 +5,23 @@ class DestroyGroupService @group, @current_user = group, user end + def async_execute + group.transaction do + # Soft delete via paranoia gem + group.destroy + job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) + Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") + end + end + def execute group.projects.each do |project| + # Execute the destruction of the models immediately to ensure atomic cleanup. # Skip repository removal because we remove directory with namespace - # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! + # that contain all these repositories + ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute end - group.destroy + group.really_destroy! end end diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index c4a206f785e..9bd4bd464f7 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -15,6 +15,9 @@ module Files else params[:file_content] end + @last_commit_sha = params[:last_commit_sha] + @author_email = params[:author_email] + @author_name = params[:author_name] # Validate parameters validate @@ -24,8 +27,9 @@ module Files create_target_branch end - if commit - success + result = commit + if result + success(result: result) else error('Something went wrong. Your changes were not committed') end @@ -39,6 +43,12 @@ module Files @source_branch != @target_branch || @source_project != @project end + def file_has_changed? + return false unless @last_commit_sha && last_commit + + @last_commit_sha != last_commit.sha + end + def raise_error(message) raise ValidationError.new(message) end diff --git a/app/services/files/create_dir_service.rb b/app/services/files/create_dir_service.rb index 6107254a34e..d00d78cee7e 100644 --- a/app/services/files/create_dir_service.rb +++ b/app/services/files/create_dir_service.rb @@ -3,7 +3,7 @@ require_relative "base_service" module Files class CreateDirService < Files::BaseService def commit - repository.commit_dir(current_user, @file_path, @commit_message, @target_branch) + repository.commit_dir(current_user, @file_path, @commit_message, @target_branch, author_email: @author_email, author_name: @author_name) end def validate diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb index 8eaf6db8012..bf127843d55 100644 --- a/app/services/files/create_service.rb +++ b/app/services/files/create_service.rb @@ -3,7 +3,7 @@ require_relative "base_service" module Files class CreateService < Files::BaseService def commit - repository.commit_file(current_user, @file_path, @file_content, @commit_message, @target_branch, false) + repository.commit_file(current_user, @file_path, @file_content, @commit_message, @target_branch, false, author_email: @author_email, author_name: @author_name) end def validate diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb index 27c881c3430..8b27ad51789 100644 --- a/app/services/files/delete_service.rb +++ b/app/services/files/delete_service.rb @@ -3,7 +3,7 @@ require_relative "base_service" module Files class DeleteService < Files::BaseService def commit - repository.remove_file(current_user, @file_path, @commit_message, @target_branch) + repository.remove_file(current_user, @file_path, @commit_message, @target_branch, author_email: @author_email, author_name: @author_name) end end end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb new file mode 100644 index 00000000000..d28912e1301 --- /dev/null +++ b/app/services/files/multi_service.rb @@ -0,0 +1,124 @@ +require_relative "base_service" + +module Files + class MultiService < Files::BaseService + class FileChangedError < StandardError; end + + def commit + repository.multi_action( + user: current_user, + branch: @target_branch, + message: @commit_message, + actions: params[:actions], + author_email: @author_email, + author_name: @author_name + ) + end + + private + + def validate + super + + params[:actions].each_with_index do |action, index| + unless action[:file_path].present? + raise_error("You must specify a file_path.") + end + + regex_check(action[:file_path]) + regex_check(action[:previous_path]) if action[:previous_path] + + if project.empty_repo? && action[:action] != :create + raise_error("No files to #{action[:action]}.") + end + + validate_file_exists(action) + + case action[:action] + when :create + validate_create(action) + when :update + validate_update(action) + when :delete + validate_delete(action) + when :move + validate_move(action, index) + else + raise_error("Unknown action type `#{action[:action]}`.") + end + end + end + + def validate_file_exists(action) + return if action[:action] == :create + + file_path = action[:file_path] + file_path = action[:previous_path] if action[:action] == :move + + blob = repository.blob_at_branch(params[:branch_name], file_path) + + unless blob + raise_error("File to be #{action[:action]}d `#{file_path}` does not exist.") + end + end + + def last_commit + Gitlab::Git::Commit.last_for_path(repository, @source_branch, @file_path) + end + + def regex_check(file) + if file =~ Gitlab::Regex.directory_traversal_regex + raise_error( + 'Your changes could not be committed, because the file name, `' + + file + + '` ' + + Gitlab::Regex.directory_traversal_regex_message + ) + end + + unless file =~ Gitlab::Regex.file_path_regex + raise_error( + 'Your changes could not be committed, because the file name, `' + + file + + '` ' + + Gitlab::Regex.file_path_regex_message + ) + end + end + + def validate_create(action) + return if project.empty_repo? + + if repository.blob_at_branch(params[:branch_name], action[:file_path]) + raise_error("Your changes could not be committed because a file with the name `#{action[:file_path]}` already exists.") + end + end + + def validate_delete(action) + end + + def validate_move(action, index) + if action[:previous_path].nil? + raise_error("You must supply the original file path when moving file `#{action[:file_path]}`.") + end + + blob = repository.blob_at_branch(params[:branch_name], action[:file_path]) + + if blob + raise_error("Move destination `#{action[:file_path]}` already exists.") + end + + if action[:content].nil? + blob = repository.blob_at_branch(params[:branch_name], action[:previous_path]) + blob.load_all_data!(repository) if blob.truncated? + params[:actions][index][:content] = blob.data + end + end + + def validate_update(action) + if file_has_changed? + raise FileChangedError.new("You are attempting to update a file `#{action[:file_path]}` that has changed since you started editing it.") + end + end + end +end diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index 8d2b5083179..c17fdb8d1f1 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -2,11 +2,30 @@ require_relative "base_service" module Files class UpdateService < Files::BaseService + class FileChangedError < StandardError; end + def commit repository.update_file(current_user, @file_path, @file_content, branch: @target_branch, previous_path: @previous_path, - message: @commit_message) + message: @commit_message, + author_email: @author_email, + author_name: @author_name) + end + + private + + def validate + super + + if file_has_changed? + raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.") + end + end + + def last_commit + @last_commit ||= Gitlab::Git::Commit. + last_for_path(@source_project.repository, @source_branch, @file_path) end end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 3f6a177bf3a..e8415862de5 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -63,13 +63,12 @@ class GitPushService < BaseService protected def update_merge_requests - @project.update_merge_requests(params[:oldrev], params[:newrev], params[:ref], current_user) + UpdateMergeRequestsWorker.perform_async(@project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) EventCreateService.new.push(@project, current_user, build_push_data) - SystemHooksService.new.execute_hooks(build_push_data_system_hook.dup, :push_hooks) @project.execute_hooks(build_push_data.dup, :push_hooks) @project.execute_services(build_push_data.dup, :push_hooks) - CreateCommitBuildsService.new.execute(@project, current_user, build_push_data) + Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute ProjectCacheWorker.perform_async(@project.id) end @@ -87,16 +86,16 @@ class GitPushService < BaseService project.change_head(branch_name) # Set protection on the default branch if configured - if current_application_settings.default_branch_protection != PROTECTION_NONE + if current_application_settings.default_branch_protection != PROTECTION_NONE && !@project.protected_branch?(@project.default_branch) params = { name: @project.default_branch, - push_access_level_attributes: { + push_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - }, - merge_access_level_attributes: { + }], + merge_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } + }] } ProtectedBranches::CreateService.new(@project, current_user, params).execute @@ -134,17 +133,18 @@ class GitPushService < BaseService end commit.create_cross_references!(authors[commit], closed_issues) + update_issue_metrics(commit, authors) end end def build_push_data - @push_data ||= Gitlab::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits) - end - - def build_push_data_system_hook - @push_data_system ||= Gitlab::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], []) + @push_data ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + push_commits) end def push_to_existing_branch? @@ -176,4 +176,11 @@ class GitPushService < BaseService def branch_name @branch_name ||= Gitlab::Git.ref_name(params[:ref]) end + + def update_issue_metrics(commit, authors) + mentioned_issues = commit.all_references(authors[commit]).issues + + Issue::Metrics.where(issue_id: mentioned_issues.map(&:id), first_mentioned_in_commit_at: nil). + update_all(first_mentioned_in_commit_at: commit.committed_date) + end end diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 969530c4fdc..e6002b03b93 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -11,7 +11,7 @@ class GitTagPushService < BaseService SystemHooksService.new.execute_hooks(build_system_push_data.dup, :tag_push_hooks) project.execute_hooks(@push_data.dup, :tag_push_hooks) project.execute_services(@push_data.dup, :tag_push_hooks) - CreateCommitBuildsService.new.execute(project, current_user, @push_data) + Ci::CreatePipelineService.new(project, current_user, @push_data).execute ProjectCacheWorker.perform_async(project.id) true @@ -34,12 +34,24 @@ class GitTagPushService < BaseService end end - Gitlab::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message) + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + commits, + message) end def build_system_push_data - Gitlab::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '') + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + [], + '') end end diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb new file mode 100644 index 00000000000..b0e1799b489 --- /dev/null +++ b/app/services/ham_service.rb @@ -0,0 +1,26 @@ +class HamService + attr_accessor :spam_log + + def initialize(spam_log) + @spam_log = spam_log + end + + def mark_as_ham! + if akismet.submit_ham + spam_log.update_attribute(:submitted_as_ham, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spam_log.user, + spam_log.text, + ip_address: spam_log.source_ip, + user_agent: spam_log.user_agent + ) + end +end diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb new file mode 100644 index 00000000000..6442406d77e --- /dev/null +++ b/app/services/import_export_clean_up_service.rb @@ -0,0 +1,24 @@ +class ImportExportCleanUpService + LAST_MODIFIED_TIME_IN_MINUTES = 1440 + + attr_reader :mmin, :path + + def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES) + @mmin = mmin + @path = Gitlab::ImportExport.storage_path + end + + def execute + Gitlab::Metrics.measure(:import_export_clean_up) do + return unless File.directory?(path) + + clean_up_export_files + end + end + + private + + def clean_up_export_files + Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) + end +end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb new file mode 100644 index 00000000000..60891cbb255 --- /dev/null +++ b/app/services/issuable/bulk_update_service.rb @@ -0,0 +1,26 @@ +module Issuable + class BulkUpdateService < IssuableBaseService + def execute(type) + model_class = type.classify.constantize + update_class = type.classify.pluralize.constantize::UpdateService + + ids = params.delete(:issuable_ids).split(",") + items = model_class.where(id: ids) + + %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| + params.delete(key) unless params[key].present? + end + + items.each do |issuable| + next unless can?(current_user, :"update_#{type}", issuable) + + update_class.new(issuable.project, current_user, params).execute(issuable) + end + + { + count: items.count, + success: !items.count.zero? + } + end + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 2d96efe1042..57d521f2fea 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -45,10 +45,12 @@ class IssuableBaseService < BaseService unless can?(current_user, ability, project) params.delete(:milestone_id) + params.delete(:labels) params.delete(:add_label_ids) params.delete(:remove_label_ids) params.delete(:label_ids) params.delete(:assignee_id) + params.delete(:due_date) end end @@ -69,14 +71,10 @@ class IssuableBaseService < BaseService end def filter_labels - if params[:add_label_ids].present? || params[:remove_label_ids].present? - params.delete(:label_ids) - - filter_labels_in_param(:add_label_ids) - filter_labels_in_param(:remove_label_ids) - else - filter_labels_in_param(:label_ids) - end + filter_labels_in_param(:add_label_ids) + filter_labels_in_param(:remove_label_ids) + filter_labels_in_param(:label_ids) + find_or_create_label_ids end def filter_labels_in_param(key) @@ -85,30 +83,111 @@ class IssuableBaseService < BaseService params[key] = project.labels.where(id: params[key]).pluck(:id) end - def update_issuable(issuable, attributes) + def find_or_create_label_ids + labels = params.delete(:labels) + return unless labels + + params[:label_ids] = labels.split(",").map do |label_name| + project.labels.create_with(color: Label::DEFAULT_COLOR) + .find_or_create_by(title: label_name.strip) + .id + end + end + + def process_label_ids(attributes, existing_label_ids: nil) + label_ids = attributes.delete(:label_ids) + add_label_ids = attributes.delete(:add_label_ids) + remove_label_ids = attributes.delete(:remove_label_ids) + + new_label_ids = existing_label_ids || label_ids || [] + + if add_label_ids.blank? && remove_label_ids.blank? + new_label_ids = label_ids if label_ids + else + new_label_ids |= add_label_ids if add_label_ids + new_label_ids -= remove_label_ids if remove_label_ids + end + + new_label_ids + end + + def merge_slash_commands_into_params!(issuable) + description, command_params = + SlashCommands::InterpretService.new(project, current_user). + execute(params[:description], issuable) + + params[:description] = description + + params.merge!(command_params) + end + + def create_issuable(issuable, attributes, label_ids:) issuable.with_transaction_returning_status do - add_label_ids = attributes.delete(:add_label_ids) - remove_label_ids = attributes.delete(:remove_label_ids) + if issuable.save + issuable.update_attributes(label_ids: label_ids) + end + end + end - issuable.label_ids |= add_label_ids if add_label_ids - issuable.label_ids -= remove_label_ids if remove_label_ids + def create(issuable) + merge_slash_commands_into_params!(issuable) + filter_params + + params.delete(:state_event) + params[:author] ||= current_user + label_ids = process_label_ids(params) + + issuable.assign_attributes(params) + + before_create(issuable) + + if params.present? && create_issuable(issuable, params, label_ids: label_ids) + after_create(issuable) + issuable.create_cross_references!(current_user) + execute_hooks(issuable) + end + + issuable + end + + def before_create(issuable) + # To be overridden by subclasses + end - issuable.assign_attributes(attributes.merge(updated_by: current_user)) + def after_create(issuable) + # To be overridden by subclasses + end + + def after_update(issuable) + # To be overridden by subclasses + end - issuable.save + def update_issuable(issuable, attributes) + issuable.with_transaction_returning_status do + issuable.update(attributes.merge(updated_by: current_user)) end end def update(issuable) change_state(issuable) change_subscription(issuable) + change_todo(issuable) filter_params old_labels = issuable.labels.to_a + old_mentioned_users = issuable.mentioned_users.to_a + + params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids) if params.present? && update_issuable(issuable, params) issuable.reset_events_cache - handle_common_system_notes(issuable, old_labels: old_labels) - handle_changes(issuable, old_labels: old_labels) + + # We do not touch as it will affect a update on updated_at field + ActiveRecord::Base.no_touching do + handle_common_system_notes(issuable, old_labels: old_labels) + end + + handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') end @@ -134,6 +213,16 @@ class IssuableBaseService < BaseService end end + def change_todo(issuable) + case params.delete(:todo_event) + when 'add' + todo_service.mark_todo(issuable, current_user) + when 'done' + todo = TodosFinder.new(current_user).execute.find_by(target: issuable) + todo_service.mark_todos_as_done([todo], current_user) if todo + end + end + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 089b0f527e2..9ea3ce084ba 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -14,9 +14,10 @@ module Issues end def execute_hooks(issue, action = 'open') - issue_data = hook_data(issue, action) - issue.project.execute_hooks(issue_data, :issue_hooks) - issue.project.execute_services(issue_data, :issue_hooks) + issue_data = hook_data(issue, action) + hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks + issue.project.execute_hooks(issue_data, hooks_scope) + issue.project.execute_services(issue_data, hooks_scope) end end end diff --git a/app/services/issues/bulk_update_service.rb b/app/services/issues/bulk_update_service.rb deleted file mode 100644 index 7e19a73f71a..00000000000 --- a/app/services/issues/bulk_update_service.rb +++ /dev/null @@ -1,25 +0,0 @@ -module Issues - class BulkUpdateService < BaseService - def execute - issues_ids = params.delete(:issues_ids).split(",") - issue_params = params - - %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| - issue_params.delete(key) unless issue_params[key].present? - end - - issues = Issue.where(id: issues_ids) - - issues.each do |issue| - next unless can?(current_user, :update_issue, issue) - - Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue) - end - - { - count: issues.count, - success: !issues.count.zero? - } - end - end -end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 859c934ea3b..45cca216ccc 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -1,6 +1,8 @@ module Issues class CloseService < Issues::BaseService def execute(issue, commit: nil, notifications: true, system_note: true) + return issue unless can?(current_user, :update_issue, issue) + if project.jira_tracker? && project.jira_service.active project.jira_service.execute(commit, issue) todo_service.close_issue(issue, current_user) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 5e2de2ccf64..ea1690f3e38 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -1,31 +1,33 @@ module Issues class CreateService < Issues::BaseService def execute - filter_params - label_params = params.delete(:label_ids) - request = params.delete(:request) - api = params.delete(:api) - issue = project.issues.new(params) - issue.author = params[:author] || current_user + @request = params.delete(:request) + @api = params.delete(:api) - issue.spam = spam_check_service.execute(request, api) + @issue = project.issues.new - if issue.save - issue.update_attributes(label_ids: label_params) - notification_service.new_issue(issue, current_user) - todo_service.new_issue(issue, current_user) - event_service.open_issue(issue, current_user) - issue.create_cross_references!(current_user) - execute_hooks(issue, 'open') - end + create(@issue) + end + + def before_create(issuable) + issuable.spam = spam_service.check(@api) + end - issue + def after_create(issuable) + event_service.open_issue(issuable, current_user) + notification_service.new_issue(issuable, current_user) + todo_service.new_issue(issuable, current_user) + user_agent_detail_service.create end private - def spam_check_service - SpamCheckService.new(project, current_user, params) + def spam_service + SpamService.new(@issue, @request) + end + + def user_agent_detail_service + UserAgentDetailService.new(@issue, @request) end end end diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index e48ca359f4f..40fbe354492 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -1,6 +1,8 @@ module Issues class ReopenService < Issues::BaseService def execute(issue) + return issue unless can?(current_user, :update_issue, issue) + if issue.reopen event_service.reopen_issue(issue, current_user) create_note(issue) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index c7d406cc331..a2111b3806b 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,7 +4,7 @@ module Issues update(issue) end - def handle_changes(issue, old_labels: []) + def handle_changes(issue, old_labels: [], old_mentioned_users: []) if has_changes?(issue, old_labels: old_labels) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -32,6 +32,11 @@ module Issues if added_labels.present? notification_service.relabeled_issue(issue, added_labels, current_user) end + + added_mentions = issue.mentioned_users - old_mentioned_users + if added_mentions.present? + notification_service.new_mentions_in_issue(issue, added_mentions, current_user) + end end def reopen_service diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb new file mode 100644 index 00000000000..416aee2ab51 --- /dev/null +++ b/app/services/members/approve_access_request_service.rb @@ -0,0 +1,31 @@ +module Members + class ApproveAccessRequestService < BaseService + include MembersHelper + + attr_accessor :source + + def initialize(source, current_user, params = {}) + @source = source + @current_user = current_user + @params = params + end + + def execute + condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] } + access_requester = source.requesters.find_by!(condition) + + raise Gitlab::Access::AccessDeniedError unless can_update_access_requester?(access_requester) + + access_requester.access_level = params[:access_level] if params[:access_level] + access_requester.accept_request + + access_requester + end + + private + + def can_update_access_requester?(access_requester) + access_requester && can?(current_user, action_member_permission(:update, access_requester), access_requester) + end + end +end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb new file mode 100644 index 00000000000..b7a244c2029 --- /dev/null +++ b/app/services/members/authorized_destroy_service.rb @@ -0,0 +1,21 @@ +module Members + class AuthorizedDestroyService < BaseService + attr_accessor :member, :user + + def initialize(member, user = nil) + @member, @user = member, user + end + + def execute + return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user) + + member.destroy + + if member.request? && member.user != user + notification_service.decline_access_request(member) + end + + member + end + end +end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 15358f80208..431da8372c9 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -1,21 +1,42 @@ module Members class DestroyService < BaseService - attr_accessor :member, :current_user + include MembersHelper - def initialize(member, user) - @member, @current_user = member, user + attr_accessor :source + + ALLOWED_SCOPES = %i[members requesters all] + + def initialize(source, current_user, params = {}) + @source = source + @current_user = current_user + @params = params end - def execute - unless member && can?(current_user, "destroy_#{member.type.underscore}".to_sym, member) - raise Gitlab::Access::AccessDeniedError - end + def execute(scope = :members) + raise "scope :#{scope} is not allowed!" unless ALLOWED_SCOPES.include?(scope) - member.destroy + member = find_member!(scope) - if member.request? && member.user != current_user - notification_service.decline_access_request(member) + raise Gitlab::Access::AccessDeniedError unless can_destroy_member?(member) + + AuthorizedDestroyService.new(member, current_user).execute + end + + private + + def find_member!(scope) + condition = params[:user_id] ? { user_id: params[:user_id] } : { id: params[:id] } + case scope + when :all + source.members.find_by(condition) || + source.requesters.find_by!(condition) + else + source.public_send(scope).find_by!(condition) end end + + def can_destroy_member?(member) + member && can?(current_user, action_member_permission(:destroy, member), member) + end end end diff --git a/app/services/members/request_access_service.rb b/app/services/members/request_access_service.rb new file mode 100644 index 00000000000..2614153d900 --- /dev/null +++ b/app/services/members/request_access_service.rb @@ -0,0 +1,25 @@ +module Members + class RequestAccessService < BaseService + attr_accessor :source + + def initialize(source, current_user) + @source = source + @current_user = current_user + end + + def execute + raise Gitlab::Access::AccessDeniedError unless can_request_access?(source) + + source.members.create( + access_level: Gitlab::Access::DEVELOPER, + user: current_user, + requested_at: Time.now.utc) + end + + private + + def can_request_access?(source) + source && can?(current_user, :request_access, source) + end + end +end diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb index 566049525cb..d572a928a42 100644 --- a/app/services/merge_requests/add_todo_when_build_fails_service.rb +++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb @@ -2,14 +2,14 @@ module MergeRequests class AddTodoWhenBuildFailsService < MergeRequests::BaseService # Adds a todo to the parent merge_request when a CI build fails def execute(commit_status) - each_merge_request(commit_status) do |merge_request| + commit_status_merge_requests(commit_status) do |merge_request| todo_service.merge_request_build_failed(merge_request) end end # Closes any pending build failed todos for the parent MRs when a build is retried def close(commit_status) - each_merge_request(commit_status) do |merge_request| + commit_status_merge_requests(commit_status) do |merge_request| todo_service.merge_request_build_retried(merge_request) end end diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb new file mode 100644 index 00000000000..f636e5fec4f --- /dev/null +++ b/app/services/merge_requests/assign_issues_service.rb @@ -0,0 +1,35 @@ +module MergeRequests + class AssignIssuesService < BaseService + def assignable_issues + @assignable_issues ||= begin + if current_user == merge_request.author + closes_issues.select do |issue| + !issue.assignee_id? && can?(current_user, :admin_issue, issue) + end + else + [] + end + end + end + + def execute + assignable_issues.each do |issue| + Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) + end + + { + count: assignable_issues.count + } + end + + private + + def merge_request + params[:merge_request] + end + + def closes_issues + @closes_issues ||= params[:closes_issues] || merge_request.closes_issues(current_user) + end + end +end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index ba424b09463..58f69a41e14 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -5,16 +5,17 @@ module MergeRequests end def create_title_change_note(issuable, old_title) - removed_wip = old_title =~ MergeRequest::WIP_REGEX && !issuable.work_in_progress? - added_wip = old_title !~ MergeRequest::WIP_REGEX && issuable.work_in_progress? + removed_wip = MergeRequest.work_in_progress?(old_title) && !issuable.work_in_progress? + added_wip = !MergeRequest.work_in_progress?(old_title) && issuable.work_in_progress? + changed_title = MergeRequest.wipless_title(old_title) != issuable.wipless_title if removed_wip SystemNoteService.remove_merge_request_wip(issuable, issuable.project, current_user) elsif added_wip SystemNoteService.add_merge_request_wip(issuable, issuable.project, current_user) - else - super end + + super if changed_title end def hook_data(merge_request, action, oldrev = nil) @@ -41,28 +42,33 @@ module MergeRequests super(:merge_request) end - def merge_request_from(commit_status) - branches = commit_status.ref + def merge_requests_for(branch) + origin_merge_requests = @project.origin_merge_requests + .opened.where(source_branch: branch).to_a - # This is for ref-less builds - branches ||= @project.repository.branch_names_contains(commit_status.sha) + fork_merge_requests = @project.fork_merge_requests + .opened.where(source_branch: branch).to_a - return [] if branches.blank? + (origin_merge_requests + fork_merge_requests) + .uniq.select(&:source_project) + end - merge_requests = @project.origin_merge_requests.opened.where(source_branch: branches).to_a - merge_requests += @project.fork_merge_requests.opened.where(source_branch: branches).to_a + def pipeline_merge_requests(pipeline) + merge_requests_for(pipeline.ref).each do |merge_request| + next unless pipeline == merge_request.pipeline - merge_requests.uniq.select(&:source_project) + yield merge_request + end end - def each_merge_request(commit_status) - merge_request_from(commit_status).each do |merge_request| + def commit_status_merge_requests(commit_status) + merge_requests_for(commit_status.ref).each do |merge_request| pipeline = merge_request.pipeline next unless pipeline next unless pipeline.sha == commit_status.sha - yield merge_request, pipeline + yield merge_request end end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 290742f1506..404f75616b5 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -4,7 +4,7 @@ module MergeRequests merge_request = MergeRequest.new(params) # Set MR attributes - merge_request.can_be_created = false + merge_request.can_be_created = true merge_request.compare_commits = [] merge_request.source_project = project unless merge_request.source_project @@ -22,6 +22,12 @@ module MergeRequests return build_failed(merge_request, message) end + if merge_request.source_project == merge_request.target_project && + merge_request.target_branch == merge_request.source_branch + + return build_failed(merge_request, 'You must select different branches') + end + compare = CompareService.new.execute( merge_request.source_project, merge_request.source_branch, @@ -29,17 +35,8 @@ module MergeRequests merge_request.target_branch, ) - commits = compare.commits - - # At this point we decide if merge request can be created - # If we have at least one commit to merge -> creation allowed - if commits.present? - merge_request.compare_commits = commits - merge_request.can_be_created = true - merge_request.compare = compare - else - merge_request.can_be_created = false - end + merge_request.compare_commits = compare.commits + merge_request.compare = compare set_title_and_description(merge_request) end @@ -83,12 +80,14 @@ module MergeRequests closes_issue = "Closes ##{iid}" if merge_request.description.present? - merge_request.description += closes_issue.prepend("\n") + merge_request.description += closes_issue.prepend("\n\n") else merge_request.description = closes_issue end end + merge_request.title = merge_request.wip_title if commits.empty? + merge_request end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index 27ee81fe3e7..f2053bda83a 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -1,6 +1,8 @@ module MergeRequests class CloseService < MergeRequests::BaseService def execute(merge_request, commit = nil) + return merge_request unless can?(current_user, :update_merge_request, merge_request) + # If we close MergeRequest we want to ignore validation # so we can close broken one (Ex. fork project removed) merge_request.allow_broken = true diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 22168ae5b2c..89c3c3bdada 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -7,24 +7,19 @@ module MergeRequests source_project = @project @project = Project.find(params[:target_project_id]) if params[:target_project_id] - filter_params - label_params = params.delete(:label_ids) + params[:target_project_id] ||= source_project.id - merge_request = MergeRequest.new(params) + merge_request = MergeRequest.new merge_request.source_project = source_project - merge_request.target_project ||= source_project - merge_request.author = current_user - if merge_request.save - merge_request.update_attributes(label_ids: label_params) - event_service.open_mr(merge_request, current_user) - notification_service.new_merge_request(merge_request, current_user) - todo_service.new_merge_request(merge_request, current_user) - merge_request.create_cross_references!(current_user) - execute_hooks(merge_request) - end + create(merge_request) + end - merge_request + def after_create(issuable) + event_service.open_mr(issuable, current_user) + notification_service.new_merge_request(issuable, current_user) + todo_service.new_merge_request(issuable, current_user) + issuable.cache_merge_request_closes_issues!(current_user) end end end diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb new file mode 100644 index 00000000000..1262ecbc29a --- /dev/null +++ b/app/services/merge_requests/get_urls_service.rb @@ -0,0 +1,63 @@ +module MergeRequests + class GetUrlsService < BaseService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute(changes) + branches = get_branches(changes) + merge_requests_map = opened_merge_requests_from_source_branches(branches) + branches.map do |branch| + existing_merge_request = merge_requests_map[branch] + if existing_merge_request + url_for_existing_merge_request(existing_merge_request) + else + url_for_new_merge_request(branch) + end + end + end + + private + + def opened_merge_requests_from_source_branches(branches) + merge_requests = MergeRequest.from_project(project).opened.from_source_branches(branches) + merge_requests.inject({}) do |hash, mr| + hash[mr.source_branch] = mr + hash + end + end + + def get_branches(changes) + return [] if project.empty_repo? + return [] unless project.merge_requests_enabled? + + changes_list = Gitlab::ChangesList.new(changes) + changes_list.map do |change| + next unless Gitlab::Git.branch_ref?(change[:ref]) + + # Deleted branch + next if Gitlab::Git.blank_ref?(change[:newrev]) + + # Default branch + branch_name = Gitlab::Git.branch_name(change[:ref]) + next if branch_name == project.default_branch + + branch_name + end.compact + end + + def url_for_new_merge_request(branch_name) + merge_request_params = { source_branch: branch_name } + url = Gitlab::Routing.url_helpers.new_namespace_project_merge_request_url(project.namespace, project, merge_request: merge_request_params) + { branch_name: branch_name, url: url, new_merge_request: true } + end + + def url_for_existing_merge_request(merge_request) + target_project = merge_request.target_project + url = Gitlab::Routing.url_helpers.namespace_project_merge_request_url(target_project.namespace, target_project, merge_request) + { branch_name: merge_request.source_branch, url: url, new_merge_request: false } + end + end +end diff --git a/app/services/merge_requests/merge_when_build_succeeds_service.rb b/app/services/merge_requests/merge_when_build_succeeds_service.rb index eefe6cdb114..cc244946eae 100644 --- a/app/services/merge_requests/merge_when_build_succeeds_service.rb +++ b/app/services/merge_requests/merge_when_build_succeeds_service.rb @@ -16,12 +16,13 @@ module MergeRequests merge_request.save end - # Triggers the automatic merge of merge_request once the build succeeds - def trigger(commit_status) - each_merge_request(commit_status) do |merge_request, pipeline| + # Triggers the automatic merge of merge_request once the pipeline succeeds + def trigger(pipeline) + return unless pipeline.success? + + pipeline_merge_requests(pipeline) do |merge_request| next unless merge_request.merge_when_build_succeeds? next unless merge_request.mergeable? - next unless pipeline.success? MergeWorker.perform_async(merge_request.id, merge_request.merge_user_id) end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 8437d9b8b43..e8fb1b59752 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -7,6 +7,7 @@ module MergeRequests class PostMergeService < MergeRequests::BaseService def execute(merge_request) close_issues(merge_request) + todo_service.merge_merge_request(merge_request, current_user) merge_request.mark_as_merged create_merge_event(merge_request, current_user) create_note(merge_request) diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 5cedd6f11d9..22596b4014a 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -13,6 +13,7 @@ module MergeRequests reload_merge_requests reset_merge_when_build_succeeds mark_pending_todos_done + cache_merge_requests_closing_issues # Leave a system note if a branch was deleted/added if branch_added? || branch_removed? @@ -141,6 +142,14 @@ module MergeRequests end end + # If the merge requests closes any issues, save this information in the + # `MergeRequestsClosingIssues` model (as a performance optimization). + def cache_merge_requests_closing_issues + @project.merge_requests.where(source_branch: @branch_name).each do |merge_request| + merge_request.cache_merge_request_closes_issues!(@current_user) + end + end + def filter_merge_requests(merge_requests) merge_requests.uniq.select(&:source_project) end diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index eb88ae9d11c..fadcce5d9b6 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -1,6 +1,8 @@ module MergeRequests class ReopenService < MergeRequests::BaseService def execute(merge_request) + return merge_request unless can?(current_user, :update_merge_request, merge_request) + if merge_request.reopen event_service.reopen_mr(merge_request, current_user) create_note(merge_request) diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb new file mode 100644 index 00000000000..19caa038c44 --- /dev/null +++ b/app/services/merge_requests/resolve_service.rb @@ -0,0 +1,50 @@ +module MergeRequests + class ResolveService < MergeRequests::BaseService + attr_accessor :conflicts, :rugged, :merge_index, :merge_request + + def execute(merge_request) + @conflicts = merge_request.conflicts + @rugged = project.repository.rugged + @merge_index = conflicts.merge_index + @merge_request = merge_request + + fetch_their_commit! + + conflicts.files.each do |file| + write_resolved_file_to_index(file, params[:sections]) + end + + commit_params = { + message: params[:commit_message] || conflicts.default_commit_message, + parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid), + tree: merge_index.write_tree(rugged) + } + + project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params) + end + + def write_resolved_file_to_index(file, resolutions) + new_file = file.resolve_lines(resolutions).map(&:text).join("\n") + our_path = file.our_path + + merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) + merge_index.conflict_remove(our_path) + end + + # If their commit (in the target project) doesn't exist in the source project, it + # can't be a parent for the merge commit we're about to create. If that's the case, + # fetch the target branch ref into the source project so the commit exists in both. + # + def fetch_their_commit! + return if rugged.include?(conflicts.their_commit.oid) + + random_string = SecureRandom.hex + + project.repository.fetch_ref( + merge_request.target_project.repository.path_to_repo, + "refs/heads/#{merge_request.target_branch}", + "refs/tmp/#{random_string}/head" + ) + end + end +end diff --git a/app/services/merge_requests/resolved_discussion_notification_service.rb b/app/services/merge_requests/resolved_discussion_notification_service.rb new file mode 100644 index 00000000000..3a09350c847 --- /dev/null +++ b/app/services/merge_requests/resolved_discussion_notification_service.rb @@ -0,0 +1,10 @@ +module MergeRequests + class ResolvedDiscussionNotificationService < MergeRequests::BaseService + def execute(merge_request) + return unless merge_request.discussions_resolved? + + SystemNoteService.resolve_all_discussions(merge_request, project, current_user) + notification_service.resolve_all_discussions(merge_request, current_user) + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 477c64e7377..80adde2af80 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -11,10 +11,16 @@ module MergeRequests params.except!(:target_project_id) params.except!(:source_branch) + if merge_request.closed_without_fork? + params.except!(:target_branch, :force_remove_source_branch) + end + + handle_wip_event(merge_request) + update(merge_request) end - def handle_changes(merge_request, old_labels: []) + def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -53,6 +59,15 @@ module MergeRequests current_user ) end + + added_mentions = merge_request.mentioned_users - old_mentioned_users + if added_mentions.present? + notification_service.new_mentions_in_merge_request( + merge_request, + added_mentions, + current_user + ) + end end def reopen_service @@ -62,5 +77,22 @@ module MergeRequests def close_service MergeRequests::CloseService end + + def after_update(issuable) + issuable.cache_merge_request_closes_issues!(current_user) + end + + private + + def handle_wip_event(merge_request) + if wip_event = params.delete(:wip_event) + # We update the title that is provided in the params or we use the mr title + title = params[:title] || merge_request.title + params[:title] = case wip_event + when 'wip' then MergeRequest.wip_title(title) + when 'unwip' then MergeRequest.wipless_title(title) + end + end + end end end diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb index 3b90399af64..b8e08c9f1eb 100644 --- a/app/services/milestones/create_service.rb +++ b/app/services/milestones/create_service.rb @@ -3,7 +3,7 @@ module Milestones def execute milestone = project.milestones.new(params) - if milestone.save! + if milestone.save event_service.open_milestone(milestone, current_user) end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 18971bd0be3..a36008c3ef5 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -11,10 +11,33 @@ module Notes return noteable.create_award_emoji(note.award_emoji_name, current_user) end - if note.save + # We execute commands (extracted from `params[:note]`) on the noteable + # **before** we save the note because if the note consists of commands + # only, there is no need be create a note! + slash_commands_service = SlashCommandsService.new(project, current_user) + + if slash_commands_service.supported?(note) + content, command_params = slash_commands_service.extract_commands(note) + + only_commands = content.empty? + + note.note = content + end + + if !only_commands && note.save # Finish the harder work in the background NewNoteWorker.perform_in(2.seconds, note.id, params) - TodoService.new.new_note(note, current_user) + todo_service.new_note(note, current_user) + end + + if command_params && command_params.any? + slash_commands_service.execute(command_params, note) + + # We must add the error after we call #save because errors are reset + # when #save is called + if only_commands + note.errors.add(:commands_only, 'Your commands have been executed!') + end end note diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 534c48aefff..e4cd3fc7833 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -16,7 +16,7 @@ module Notes end def hook_data - Gitlab::NoteDataBuilder.build(@note, @note.author) + Gitlab::DataBuilder::Note.build(@note, @note.author) end def execute_note_hooks diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb new file mode 100644 index 00000000000..2edbd39a9e7 --- /dev/null +++ b/app/services/notes/slash_commands_service.rb @@ -0,0 +1,36 @@ +module Notes + class SlashCommandsService < BaseService + UPDATE_SERVICES = { + 'Issue' => Issues::UpdateService, + 'MergeRequest' => MergeRequests::UpdateService + } + + def self.noteable_update_service(note) + UPDATE_SERVICES[note.noteable_type] + end + + def self.supported?(note, current_user) + noteable_update_service(note) && + current_user && + current_user.can?(:"update_#{note.noteable_type.underscore}", note.noteable) + end + + def supported?(note) + self.class.supported?(note, current_user) + end + + def extract_commands(note) + return [note.note, {}] unless supported?(note) + + SlashCommands::InterpretService.new(project, current_user). + execute(note.note, note.noteable) + end + + def execute(command_params, note) + return if command_params.empty? + return unless supported?(note) + + self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable) + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ab6e51209ee..72712afc07e 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -35,6 +35,20 @@ class NotificationService new_resource_email(issue, issue.project, :new_issue_email) end + # When issue text is updated, we should send an email to: + # + # * newly mentioned project team members with notification level higher than Participating + # + def new_mentions_in_issue(issue, new_mentioned_users, current_user) + new_mentions_in_resource_email( + issue, + issue.project, + new_mentioned_users, + current_user, + :new_mention_in_issue_email + ) + end + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled @@ -75,6 +89,20 @@ class NotificationService new_resource_email(merge_request, merge_request.target_project, :new_merge_request_email) end + # When merge request text is updated, we should send an email to: + # + # * newly mentioned project team members with notification level higher than Participating + # + def new_mentions_in_merge_request(merge_request, new_mentioned_users, current_user) + new_mentions_in_resource_email( + merge_request, + merge_request.target_project, + new_mentioned_users, + current_user, + :new_mention_in_merge_request_email + ) + end + # When we reassign a merge_request we should send an email to: # # * merge_request old assignee if their notification level is not Disabled @@ -106,7 +134,8 @@ class NotificationService merge_request, merge_request.target_project, current_user, - :merged_merge_request_email + :merged_merge_request_email, + skip_current_user: !merge_request.merge_when_build_succeeds? ) end @@ -120,6 +149,14 @@ class NotificationService ) end + def resolve_all_discussions(merge_request, current_user) + recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions") + + recipients.each do |recipient| + mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later + end + end + # Notify new user with email after creation def new_user(user, token = nil) # Don't email omniauth created users @@ -177,7 +214,7 @@ class NotificationService # build notify method like 'note_commit_email' notify_method = "note_#{note.noteable_type.underscore}_email".to_sym - + recipients.each do |recipient| mailer.send(notify_method, recipient.id, note.id).deliver_later end @@ -206,7 +243,6 @@ class NotificationService project_member.real_source_type, project_member.project.id, project_member.invite_email, - project_member.access_level, project_member.created_by_id ).deliver_later end @@ -233,7 +269,6 @@ class NotificationService group_member.real_source_type, group_member.group.id, group_member.invite_email, - group_member.access_level, group_member.created_by_id ).deliver_later end @@ -440,10 +475,12 @@ class NotificationService end def reject_users_without_access(recipients, target) - return recipients unless target.is_a?(Issue) + return recipients unless target.is_a?(Issuable) + + ability = :"read_#{target.to_ability_name}" recipients.select do |user| - user.can?(:read_issue, target) + user.can?(ability, target) end end @@ -471,9 +508,25 @@ class NotificationService end end - def close_resource_email(target, project, current_user, method) + def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method) + recipients = build_recipients(target, project, current_user, action: "new") + recipients = recipients & new_mentioned_users + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, current_user.id).deliver_later + end + end + + def close_resource_email(target, project, current_user, method, skip_current_user: true) action = method == :merged_merge_request_email ? "merge" : "close" - recipients = build_recipients(target, project, current_user, action: action) + + recipients = build_recipients( + target, + project, + current_user, + action: action, + skip_current_user: skip_current_user + ) recipients.each do |recipient| mailer.send(method, recipient.id, target.id, current_user.id).deliver_later @@ -514,7 +567,7 @@ class NotificationService end end - def build_recipients(target, project, current_user, action: nil, previous_assignee: nil) + def build_recipients(target, project, current_user, action: nil, previous_assignee: nil, skip_current_user: true) custom_action = build_custom_key(action, target) recipients = target.participants(current_user) @@ -543,7 +596,8 @@ class NotificationService recipients = reject_unsubscribed_users(recipients, target) recipients = reject_users_without_access(recipients, target) - recipients.delete(current_user) + recipients.delete(current_user) if skip_current_user + recipients.uniq end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 23b6668e0d1..f578f8dbea2 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -1,7 +1,7 @@ module Projects class AutocompleteService < BaseService def issues - @project.issues.visible_to_user(current_user).opened.select([:iid, :title]) + IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end def milestones @@ -9,11 +9,34 @@ module Projects end def merge_requests - @project.merge_requests.opened.select([:iid, :title]) + MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end def labels @project.labels.select([:title, :color]) end + + def commands(noteable, type) + noteable ||= + case type + when 'Issue' + @project.issues.build + when 'MergeRequest' + @project.merge_requests.build + end + + return [] unless noteable && noteable.is_a?(Issuable) + + opts = { + project: project, + issuable: noteable, + current_user: current_user + } + SlashCommands::InterpretService.command_definitions.map do |definition| + next unless definition.available?(opts) + + definition.to_h(opts) + end.compact + end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 55956be2844..15d7918e7fd 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -7,6 +7,7 @@ module Projects def execute forked_from_project_id = params.delete(:forked_from_project_id) import_data = params.delete(:import_data) + @skip_wiki = params.delete(:skip_wiki) @project = Project.new(params) @@ -16,6 +17,11 @@ module Projects return @project end + unless allowed_fork?(forked_from_project_id) + @project.errors.add(:forked_from_project_id, 'is forbidden') + return @project + end + # Set project name from path if @project.name.present? && @project.path.present? # if both name and path set - everything is ok @@ -72,6 +78,13 @@ module Projects @project.errors.add(:namespace, "is not valid") end + def allowed_fork?(source_project_id) + return true if source_project_id.nil? + + source_project = Project.find_by(id: source_project_id) + current_user.can?(:fork_project, source_project) + end + def allowed_namespace?(user, namespace_id) namespace = Namespace.find_by(id: namespace_id) current_user.can?(:create_projects, namespace) @@ -81,8 +94,7 @@ module Projects log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"") unless @project.gitlab_project_import? - @project.create_wiki if @project.wiki_enabled? - + @project.create_wiki unless skip_wiki? @project.build_missing_services @project.create_labels @@ -96,6 +108,10 @@ module Projects end end + def skip_wiki? + !@project.feature_available?(:wiki, current_user) || @skip_wiki + end + def save_project_and_import_data(import_data) Project.transaction do @project.create_or_update_import_data(data: import_data[:data], credentials: import_data[:credentials]) if import_data diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 882606e38d0..a08c6fcd94b 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -6,8 +6,12 @@ module Projects DELETED_FLAG = '+deleted' - def pending_delete! - project.schedule_delete!(current_user.id, params) + def async_execute + project.transaction do + project.update_attribute(:pending_delete, true) + job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params) + Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}") + end end def execute @@ -23,6 +27,8 @@ module Projects # Git data (e.g. a list of branch names). flush_caches(project, wiki_path) + Projects::UnlinkForkService.new(project, current_user).execute + Project.transaction do project.destroy! diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb new file mode 100644 index 00000000000..3cf4264ce9b --- /dev/null +++ b/app/services/projects/enable_deploy_key_service.rb @@ -0,0 +1,17 @@ +module Projects + class EnableDeployKeyService < BaseService + def execute + key = accessible_keys.find_by(id: params[:key_id] || params[:id]) + return unless key + + project.deploy_keys << key + key + end + + private + + def accessible_keys + current_user.accessible_deploy_keys + end + end +end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index de6dc38cc8e..a2b23ea6171 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -8,7 +8,6 @@ module Projects name: @project.name, path: @project.path, shared_runners_enabled: @project.shared_runners_enabled, - builds_enabled: @project.builds_enabled, namespace_id: @params[:namespace].try(:id) || current_user.namespace.id } @@ -17,6 +16,11 @@ module Projects end new_project = CreateService.new(current_user, new_params).execute + return new_project unless new_project.persisted? + + builds_access_level = @project.project_feature.builds_access_level + new_project.project_feature.update_attributes(builds_access_level: builds_access_level) + new_project end diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 29b3981f49f..c3dfc8cfbe8 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -30,10 +30,8 @@ module Projects end def increment! - if Gitlab::ExclusiveLease.new("project_housekeeping:increment!:#{@project.id}", timeout: 60).try_obtain - Gitlab::Metrics.measure(:increment_pushes_since_gc) do - update_pushes_since_gc(@project.pushes_since_gc + 1) - end + Gitlab::Metrics.measure(:increment_pushes_since_gc) do + @project.increment_pushes_since_gc end end @@ -43,14 +41,10 @@ module Projects GitGarbageCollectWorker.perform_async(@project.id) ensure Gitlab::Metrics.measure(:reset_pushes_since_gc) do - update_pushes_since_gc(0) + @project.reset_pushes_since_gc end end - def update_pushes_since_gc(new_value) - @project.update_column(:pushes_since_gc, new_value) - end - def try_obtain_lease Gitlab::Metrics.measure(:obtain_housekeeping_lease) do lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT) diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index cdad0426b02..e466ffa60eb 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -44,6 +44,11 @@ module Projects begin gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url) rescue => e + # Expire cache to prevent scenarios such as: + # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true + # 2. Retried import, repo is broken or not imported but +exists?+ still returns true + project.repository.before_import if project.repository_exists? + raise Error, "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}" end end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 02c4eee3d02..d38328403c1 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -1,40 +1,28 @@ module Projects class ParticipantsService < BaseService - def execute(noteable_type, noteable_id) - @noteable_type = noteable_type - @noteable_id = noteable_id + attr_reader :noteable + + def execute(noteable) + @noteable = noteable + project_members = sorted(project.team.members) - participants = target_owner + participants_in_target + all_members + groups + project_members + participants = noteable_owner + participants_in_noteable + all_members + groups + project_members participants.uniq end - def target - @target ||= - case @noteable_type - when "Issue" - project.issues.find_by_iid(@noteable_id) - when "MergeRequest" - project.merge_requests.find_by_iid(@noteable_id) - when "Commit" - project.commit(@noteable_id) - else - nil - end - end - - def target_owner - return [] unless target && target.author.present? + def noteable_owner + return [] unless noteable && noteable.author.present? [{ - name: target.author.name, - username: target.author.username + name: noteable.author.name, + username: noteable.author.username }] end - def participants_in_target - return [] unless target + def participants_in_noteable + return [] unless noteable - users = target.participants(current_user) + users = noteable.participants(current_user) sorted(users) end diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 6150a2a83c9..a84e335340d 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -5,23 +5,7 @@ module ProtectedBranches def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - protected_branch = project.protected_branches.new(params) - - ProtectedBranch.transaction do - protected_branch.save! - - if protected_branch.push_access_level.blank? - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - end - - if protected_branch.merge_access_level.blank? - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) - end - end - - protected_branch - rescue ActiveRecord::RecordInvalid - protected_branch + project.protected_branches.create(params) end end end diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb index 0b56b09738d..aa84d36a206 100644 --- a/app/services/repository_archive_clean_up_service.rb +++ b/app/services/repository_archive_clean_up_service.rb @@ -1,6 +1,8 @@ class RepositoryArchiveCleanUpService LAST_MODIFIED_TIME_IN_MINUTES = 120 + attr_reader :mmin, :path + def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES) @mmin = mmin @path = Gitlab.config.gitlab.repository_downloads_path @@ -17,8 +19,6 @@ class RepositoryArchiveCleanUpService private - attr_reader :mmin, :path - def clean_up_old_archives run(%W(find #{path} -not -path #{path} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -maxdepth 2 -mmin +#{mmin} -delete)) end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb new file mode 100644 index 00000000000..e4ae3dec8aa --- /dev/null +++ b/app/services/slash_commands/interpret_service.rb @@ -0,0 +1,263 @@ +module SlashCommands + class InterpretService < BaseService + include Gitlab::SlashCommands::Dsl + + attr_reader :issuable + + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and hash of changes to be applied to a record. + def execute(content, issuable) + @issuable = issuable + @updates = {} + + opts = { + issuable: issuable, + current_user: current_user, + project: project + } + + content, commands = extractor.extract_commands(content, opts) + + commands.each do |name, arg| + definition = self.class.command_definitions_by_name[name.to_sym] + next unless definition + + definition.execute(self, opts, arg) + end + + [content, @updates] + end + + private + + def extractor + Gitlab::SlashCommands::Extractor.new(self.class.command_definitions) + end + + desc do + "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + condition do + issuable.persisted? && + issuable.open? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :close do + @updates[:state_event] = 'close' + end + + desc do + "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + condition do + issuable.persisted? && + issuable.closed? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :reopen do + @updates[:state_event] = 'reopen' + end + + desc 'Change title' + params '<New title>' + condition do + issuable.persisted? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :title do |title_param| + @updates[:title] = title_param + end + + desc 'Assign' + params '@user' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :assign do |assignee_param| + user = extract_references(assignee_param, :user).first + user ||= User.find_by(username: assignee_param) + + @updates[:assignee_id] = user.id if user + end + + desc 'Remove assignee' + condition do + issuable.persisted? && + issuable.assignee_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unassign do + @updates[:assignee_id] = nil + end + + desc 'Set milestone' + params '%"milestone"' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + project.milestones.active.any? + end + command :milestone do |milestone_param| + milestone = extract_references(milestone_param, :milestone).first + milestone ||= project.milestones.find_by(title: milestone_param.strip) + + @updates[:milestone_id] = milestone.id if milestone + end + + desc 'Remove milestone' + condition do + issuable.persisted? && + issuable.milestone_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_milestone do + @updates[:milestone_id] = nil + end + + desc 'Add label(s)' + params '~label1 ~"label 2"' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + project.labels.any? + end + command :label do |labels_param| + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:add_label_ids] ||= [] + @updates[:add_label_ids] += label_ids + + @updates[:add_label_ids].uniq! + end + end + + desc 'Remove all or specific label(s)' + params '~label1 ~"label 2"' + condition do + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unlabel do |labels_param = nil| + if labels_param.present? + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:remove_label_ids] ||= [] + @updates[:remove_label_ids] += label_ids + + @updates[:remove_label_ids].uniq! + end + else + @updates[:label_ids] = [] + end + end + + desc 'Replace all label(s)' + params '~label1 ~"label 2"' + condition do + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :relabel do |labels_param| + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:label_ids] ||= [] + @updates[:label_ids] += label_ids + + @updates[:label_ids].uniq! + end + end + + desc 'Add a todo' + condition do + issuable.persisted? && + !TodoService.new.todo_exist?(issuable, current_user) + end + command :todo do + @updates[:todo_event] = 'add' + end + + desc 'Mark todo as done' + condition do + issuable.persisted? && + TodoService.new.todo_exist?(issuable, current_user) + end + command :done do + @updates[:todo_event] = 'done' + end + + desc 'Subscribe' + condition do + issuable.persisted? && + !issuable.subscribed?(current_user) + end + command :subscribe do + @updates[:subscription_event] = 'subscribe' + end + + desc 'Unsubscribe' + condition do + issuable.persisted? && + issuable.subscribed?(current_user) + end + command :unsubscribe do + @updates[:subscription_event] = 'unsubscribe' + end + + desc 'Set due date' + params '<in 2 days | this Friday | December 31st>' + condition do + issuable.respond_to?(:due_date) && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :due do |due_date_param| + due_date = Chronic.parse(due_date_param).try(:to_date) + + @updates[:due_date] = due_date if due_date + end + + desc 'Remove due date' + condition do + issuable.persisted? && + issuable.respond_to?(:due_date) && + issuable.due_date? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_due_date do + @updates[:due_date] = nil + end + + desc do + "Toggle the Work In Progress status" + end + condition do + issuable.persisted? && + issuable.respond_to?(:work_in_progress?) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :wip do + @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' + end + + # This is a dummy command, so that it appears in the autocomplete commands + desc 'CC' + params '@user' + command :cc + + def find_label_ids(labels_param) + label_ids_by_reference = extract_references(labels_param, :label).map(&:id) + labels_ids_by_name = @project.labels.where(name: labels_param.split).select(:id) + + label_ids_by_reference | labels_ids_by_name + end + + def extract_references(arg, type) + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(arg, author: current_user) + + ext.references(type) + end + end +end diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb deleted file mode 100644 index 7c3e692bde9..00000000000 --- a/app/services/spam_check_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -class SpamCheckService < BaseService - include Gitlab::AkismetHelper - - attr_accessor :request, :api - - def execute(request, api) - @request, @api = request, api - return false unless request || check_for_spam?(project) - return false unless is_spam?(request.env, current_user, text) - - create_spam_log - - true - end - - private - - def text - [params[:title], params[:description]].reject(&:blank?).join("\n") - end - - def spam_log_attrs - { - user_id: current_user.id, - project_id: project.id, - title: params[:title], - description: params[:description], - source_ip: client_ip(request.env), - user_agent: user_agent(request.env), - noteable_type: 'Issue', - via_api: api - } - end - - def create_spam_log - CreateSpamLogService.new(project, current_user, spam_log_attrs).execute - end -end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb new file mode 100644 index 00000000000..48903291799 --- /dev/null +++ b/app/services/spam_service.rb @@ -0,0 +1,78 @@ +class SpamService + attr_accessor :spammable, :request, :options + + def initialize(spammable, request = nil) + @spammable = spammable + @request = request + @options = {} + + if @request + @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s + @options[:user_agent] = @request.env['HTTP_USER_AGENT'] + @options[:referrer] = @request.env['HTTP_REFERRER'] + else + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent + end + end + + def check(api = false) + return false unless request && check_for_spam? + + return false unless akismet.is_spam? + + create_spam_log(api) + true + end + + def mark_as_spam! + return false unless spammable.submittable_as_spam? + + if akismet.submit_spam + spammable.user_agent_detail.update_attribute(:submitted, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spammable_owner, + spammable.spammable_text, + options + ) + end + + def spammable_owner + @user ||= User.find(spammable_owner_id) + end + + def spammable_owner_id + @owner_id ||= + if spammable.respond_to?(:author_id) + spammable.author_id + elsif spammable.respond_to?(:creator_id) + spammable.creator_id + end + end + + def check_for_spam? + spammable.check_for_spam? + end + + def create_spam_log(api) + SpamLog.create( + { + user_id: spammable_owner_id, + title: spammable.spam_title, + description: spammable.spam_description, + source_ip: options[:ip_address], + user_agent: options[:user_agent], + noteable_type: spammable.class.to_s, + via_api: api + } + ) + end +end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 1fb72cf89e9..a2bfa422c9d 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -72,7 +72,7 @@ class SystemHooksService return 'user_add_to_group' if event == :create return 'user_remove_from_group' if event == :destroy else - "#{model.class.name.downcase}_#{event.to_s}" + "#{model.class.name.downcase}_#{event}" end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index e13dc9265b8..1ce66d50368 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -24,6 +24,7 @@ module SystemNoteService body = "Added #{commits_text}:\n\n" body << existing_commit_summary(noteable, existing_commits, oldrev) body << new_commit_summary(new_commits).join("\n") + body << "\n\n[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})" create_note(noteable: noteable, project: project, author: author, note: body) end @@ -158,6 +159,12 @@ module SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end + def self.resolve_all_discussions(merge_request, project, author) + body = "Resolved all discussions" + + create_note(noteable: merge_request, project: project, author: author, note: body) + end + # Called when the title of a Noteable is changed # # noteable - Noteable object that responds to `title` @@ -239,7 +246,7 @@ module SystemNoteService 'deleted' end - body = "#{verb} #{branch_type.to_s} branch `#{branch}`".capitalize + body = "#{verb} #{branch_type} branch `#{branch}`".capitalize create_note(noteable: noteable, project: project, author: author, note: body) end @@ -248,8 +255,7 @@ module SystemNoteService # # "Started branch `201-issue-branch-button`" def new_issue_branch(issue, project, author, branch) - h = Gitlab::Routing.url_helpers - link = h.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch) + link = url_helpers.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch) body = "Started branch [`#{branch}`](#{link})" create_note(noteable: issue, project: project, author: author, note: body) @@ -263,11 +269,11 @@ module SystemNoteService # # Example Note text: # - # "mentioned in #1" + # "Mentioned in #1" # - # "mentioned in !2" + # "Mentioned in !2" # - # "mentioned in 54f7727c" + # "Mentioned in 54f7727c" # # See cross_reference_note_content. # @@ -302,7 +308,7 @@ module SystemNoteService # Check if a cross-reference is disallowed # - # This method prevents adding a "mentioned in !1" note on every single commit + # This method prevents adding a "Mentioned in !1" note on every single commit # in a merge request. Additionally, it prevents the creation of references to # external issues (which would fail). # @@ -341,7 +347,7 @@ module SystemNoteService notes = notes.where(noteable_id: noteable.id) end - notes_for_mentioner(mentioner, noteable, notes).count > 0 + notes_for_mentioner(mentioner, noteable, notes).exists? end # Build an Array of lines detailing each commit added in a merge request @@ -411,7 +417,7 @@ module SystemNoteService end def cross_reference_note_prefix - 'mentioned in ' + 'Mentioned in ' end def cross_reference_note_content(gfm_reference) @@ -460,4 +466,20 @@ module SystemNoteService def escape_html(text) Rack::Utils.escape_html(text) end + + def url_helpers + @url_helpers ||= Gitlab::Routing.url_helpers + end + + def diff_comparison_url(merge_request, project, oldrev) + diff_id = merge_request.merge_request_diff.id + + url_helpers.diffs_namespace_project_merge_request_url( + project.namespace, + project, + merge_request.iid, + diff_id: diff_id, + start_sha: oldrev + ) + end end diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb index e85e58751e7..280c81f7d2d 100644 --- a/app/services/test_hook_service.rb +++ b/app/services/test_hook_service.rb @@ -1,6 +1,6 @@ class TestHookService def execute(hook, current_user) - data = Gitlab::PushDataBuilder.build_sample(hook.project, current_user) + data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user) hook.execute(data, 'push_hooks') end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 6b48d68cccb..f8e6b2ef094 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -31,6 +31,14 @@ class TodoService mark_pending_todos_as_done(issue, current_user) end + # When we destroy an issue we should: + # + # * refresh the todos count cache for the current user + # + def destroy_issue(issue, current_user) + destroy_issuable(issue, current_user) + end + # When we reassign an issue we should: # # * create a pending todo for new assignee if issue is assigned @@ -64,6 +72,14 @@ class TodoService mark_pending_todos_as_done(merge_request, current_user) end + # When we destroy a merge request we should: + # + # * refresh the todos count cache for the current user + # + def destroy_merge_request(merge_request, current_user) + destroy_issuable(merge_request, current_user) + end + # When we reassign a merge request we should: # # * creates a pending todo for new assignee if merge request is assigned @@ -142,10 +158,16 @@ class TodoService # When user marks some todos as done def mark_todos_as_done(todos, current_user) - todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all) + mark_todos_as_done_by_ids(todos.select(&:id), current_user) + end + + def mark_todos_as_done_by_ids(ids, current_user) + todos = current_user.todos.where(id: ids) - todos.update_all(state: :done) + # Only return those that are not really on that state + marked_todos = todos.where.not(state: :done).update_all(state: :done) current_user.update_todos_count_cache + marked_todos end # When user marks an issue as todo @@ -154,6 +176,10 @@ class TodoService create_todos(current_user, attributes) end + def todo_exist?(issuable, current_user) + TodosFinder.new(current_user).execute.exists?(target: issuable) + end + private def create_todos(users, attributes) @@ -177,6 +203,10 @@ class TodoService create_mention_todos(issuable.project, issuable, author) end + def destroy_issuable(issuable, user) + user.update_todos_count_cache + end + def toggling_tasks?(issuable) issuable.previous_changes.include?('description') && issuable.tasks? && issuable.updated_tasks.any? @@ -243,12 +273,12 @@ class TodoService end def reject_users_without_access(users, project, target) - if target.is_a?(Note) && target.for_issue? + if target.is_a?(Note) && (target.for_issue? || target.for_merge_request?) target = target.noteable end - if target.is_a?(Issue) - select_users(users, :read_issue, target) + if target.is_a?(Issuable) + select_users(users, :"read_#{target.to_ability_name}", target) else select_users(users, :read_project, project) end diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb new file mode 100644 index 00000000000..a1ee3df5fe1 --- /dev/null +++ b/app/services/user_agent_detail_service.rb @@ -0,0 +1,13 @@ +class UserAgentDetailService + attr_accessor :spammable, :request + + def initialize(spammable, request) + @spammable, @request = spammable, request + end + + def create + return unless request + + spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) + end +end diff --git a/app/validators/namespace_validator.rb b/app/validators/namespace_validator.rb index 7a35958cc5f..2821ecf0a88 100644 --- a/app/validators/namespace_validator.rb +++ b/app/validators/namespace_validator.rb @@ -5,7 +5,8 @@ # Values are checked for formatting and exclusion from a list of reserved path # names. class NamespaceValidator < ActiveModel::EachValidator - RESERVED = %w( + RESERVED = %w[ + .well-known admin all assets @@ -23,6 +24,7 @@ class NamespaceValidator < ActiveModel::EachValidator projects public repository + robots.txt s search services @@ -31,7 +33,7 @@ class NamespaceValidator < ActiveModel::EachValidator u unsubscribes users - ).freeze + ].freeze def validate_each(record, attribute, value) unless value =~ Gitlab::Regex.namespace_regex diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index dd2e7ebd030..05f3d9a3b50 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -1,6 +1,8 @@ - reporter = abuse_report.reporter - user = abuse_report.user %tr + %th.visible-xs-block.visible-sm-block + %strong User %td - if user = link_to user.name, user @@ -9,6 +11,7 @@ - else (removed) %td + %strong.subheading.visible-xs-block.visible-sm-block Reported by - if reporter = link_to reporter.name, reporter - else @@ -16,16 +19,16 @@ .light.small = time_ago_with_tooltip(abuse_report.created_at) %td - = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter) + %strong.subheading.visible-xs-block.visible-sm-block Message + .message + = markdown_field(abuse_report, :message) %td - if user = link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true), - data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-xs btn-remove js-remove-tr" - - %td + data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr" - if user && !user.blocked? - = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" + = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block" - else - .btn.btn-xs.disabled + .btn.btn-sm.disabled.btn-block Already Blocked - = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr" + = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr" diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml index bc4a9cedb2c..7bbc75db9ff 100644 --- a/app/views/admin/abuse_reports/index.html.haml +++ b/app/views/admin/abuse_reports/index.html.haml @@ -1,17 +1,20 @@ -- page_title "Abuse Reports" +- page_title 'Abuse Reports' %h3.page-title Abuse Reports %hr -- if @abuse_reports.present? - .table-holder - %table.table - %thead - %tr - %th User - %th Reported by - %th Message - %th Primary action - %th - = render @abuse_reports - = paginate @abuse_reports -- else - %h4 There are no abuse reports +.abuse-reports + - if @abuse_reports.present? + .table-holder + %table.table + %thead.hidden-sm.hidden-xs + %tr + %th User + %th Reported by + %th.wide Message + %th Action + = render @abuse_reports + - else + .no-reports + %span.pull-left + There are no abuse reports! + .pull-left + = emoji_icon 'tada' diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index 92e2dae4842..9175b3d3f96 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -13,7 +13,7 @@ .col-sm-10 = f.text_area :description, class: "form-control", rows: 10 .hint - Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('markdown/markdown'), target: '_blank'}. + Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. .form-group = f.label :logo, class: 'control-label' .col-sm-10 diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 23b52d08df7..c4c68cd7891 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -49,28 +49,6 @@ = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') %span.help-block#clone-protocol-help Allow only the selected protocols to be used for Git access. - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :version_check_enabled do - = f.check_box :version_check_enabled - Version check enabled - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :email_author_in_body do - = f.check_box :email_author_in_body - Include author name in notification email body - .help-block - 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. - .form-group - = f.label :admin_notification_email, class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :admin_notification_email, class: 'form-control' - .help-block - Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. %fieldset %legend Account and Limit Settings @@ -228,6 +206,9 @@ = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2' .col-sm-10 = f.number_field :max_artifacts_size, class: 'form-control' + .help-block + Set the maximum file size each build's artifacts can have + = link_to "(?)", help_page_path("user/admin_area/settings/continuous_integration", anchor: "maximum-artifacts-size") - if Gitlab.config.registry.enabled %fieldset @@ -240,7 +221,11 @@ %fieldset %legend Metrics %p - These settings require a restart to take effect. + Setup InfluxDB to measure a wide variety of statistics like the time spent + in running SQL queries. These settings require a + = link_to 'restart', help_page_path('administration/restart_gitlab') + to take effect. + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction') .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -338,6 +323,15 @@ %a{ href: 'http://www.akismet.com', target: 'blank'} http://www.akismet.com %fieldset + %legend Abuse reports + .form-group + = f.label :admin_notification_email, 'Abuse reports notification email', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :admin_notification_email, class: 'form-control' + .help-block + Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. + + %fieldset %legend Error Reporting and Logging %p These settings require a restart to take effect. @@ -363,7 +357,9 @@ .col-sm-10 = f.select :repository_storage, repository_storage_options_for_select, {}, class: 'form-control' .help-block - You can manage the repository storage paths in your gitlab.yml configuration file + Manage repository storage paths. Learn more in the + = succeed "." do + = link_to "repository storages documentation", help_page_path("administration/repository_storages") %fieldset %legend Repository Checks @@ -383,6 +379,48 @@ .help-block If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. + %fieldset + %legend Koding + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :koding_enabled do + = f.check_box :koding_enabled + Enable Koding + .form-group + = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' + .help-block + Koding has integration enabled out of the box for the + %strong gitlab + team, and you need to provide that team's URL here. Learn more in the + = succeed "." do + = link_to "Koding administration documentation", help_page_path("administration/integration/koding") + + %fieldset + %legend Usage statistics + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :version_check_enabled do + = f.check_box :version_check_enabled + Version check enabled + .help-block + Let GitLab inform you when an update is available. + + %fieldset + %legend Email + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :email_author_in_body do + = f.check_box :email_author_in_body + Include author name in notification email body + .help-block + 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. .form-actions - = f.submit 'Save', class: 'btn btn-save'
\ No newline at end of file + = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml index 89d7a40d6b0..b3530915068 100644 --- a/app/views/admin/background_jobs/_head.html.haml +++ b/app/views/admin/background_jobs/_head.html.haml @@ -1,22 +1,25 @@ -.nav-links.sub-nav - %ul{ class: (container_class) } - = nav_link(controller: :system_info) do - = link_to admin_system_info_path, title: 'System Info' do - %span - System Info - = nav_link(controller: :background_jobs) do - = link_to admin_background_jobs_path, title: 'Background Jobs' do - %span - Background Jobs - = nav_link(controller: :logs) do - = link_to admin_logs_path, title: 'Logs' do - %span - Logs - = nav_link(controller: :health_check) do - = link_to admin_health_check_path, title: 'Health Check' do - %span - Health Check - = nav_link(controller: :requests_profiles) do - = link_to admin_requests_profiles_path, title: 'Requests Profiles' do - %span - Requests Profiles += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + = nav_link(controller: :system_info) do + = link_to admin_system_info_path, title: 'System Info' do + %span + System Info + = nav_link(controller: :background_jobs) do + = link_to admin_background_jobs_path, title: 'Background Jobs' do + %span + Background Jobs + = nav_link(controller: :logs) do + = link_to admin_logs_path, title: 'Logs' do + %span + Logs + = nav_link(controller: :health_check) do + = link_to admin_health_check_path, title: 'Health Check' do + %span + Health Check + = nav_link(controller: :requests_profiles) do + = link_to admin_requests_profiles_path, title: 'Requests Profiles' do + %span + Requests Profiles diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index 4f680b507c4..05855db963a 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -28,14 +28,10 @@ %th COMMAND %tbody - @sidekiq_processes.each do |process| - - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) - - data = process.strip.split(' ') %tr %td= gitlab_config.user - - 5.times do - %td= data.shift - %td= data.join(' ') - + - parse_sidekiq_ps(process).each do |value| + %td= value .clearfix %p %i.fa.fa-exclamation-circle diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 6b157abf842..3132d157f29 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,7 +1,10 @@ .broadcast-message-preview{ style: broadcast_message_style(@broadcast_message) } = icon('bullhorn') .js-broadcast-message-preview - = render_broadcast_message(@broadcast_message.message.presence || "Your message here") + - if @broadcast_message.message.present? + = render_broadcast_message(@broadcast_message) + - else + = "Your message here" = form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form form-horizontal js-quick-submit js-requires-input'} do |f| = form_errors(@broadcast_message) @@ -18,11 +21,11 @@ .form-group.js-toggle-colors-container.hide = f.label :color, "Background Color", class: 'control-label' .col-sm-10 - = f.color_field :color, class: "form-control" + = f.text_field :color, class: "form-control" .form-group.js-toggle-colors-container.hide = f.label :font, "Font Color", class: 'control-label' .col-sm-10 - = f.color_field :font, class: "form-control" + = f.text_field :font, class: "form-control" .form-group = f.label :starts_at, class: 'control-label' .col-sm-10.datetime-controls diff --git a/app/views/admin/broadcast_messages/preview.js.haml b/app/views/admin/broadcast_messages/preview.js.haml index fbc9453c72e..c72e59640d7 100644 --- a/app/views/admin/broadcast_messages/preview.js.haml +++ b/app/views/admin/broadcast_messages/preview.js.haml @@ -1 +1 @@ -$('.js-broadcast-message-preview').html("#{j(render_broadcast_message(@message))}"); +$('.js-broadcast-message-preview').html("#{j(render_broadcast_message(@broadcast_message))}"); diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml deleted file mode 100644 index 352adbedee4..00000000000 --- a/app/views/admin/builds/_build.html.haml +++ /dev/null @@ -1,77 +0,0 @@ -- project = build.project -%tr.build.commit - %td.status - = ci_status_with_icon(build.status) - - %td - .branch-commit - - if can?(current_user, :read_build, build.project) - = link_to namespace_project_build_url(build.project.namespace, build.project, build) do - %span.build-link ##{build.id} - - else - %span.build-link ##{build.id} - - - if build.ref - .icon-container - = build.tag? ? icon('tag') : icon('code-fork') - = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" - - else - .light none - .icon-container - = custom_icon("icon_commit") - - = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace commit-id" - - if build.stuck? - %i.fa.fa-warning.text-warning - - .label-container - - if build.tags.any? - - build.tags.each do |tag| - %span.label.label-primary - = tag - - if build.try(:trigger_request) - %span.label.label-info triggered - - if build.try(:allow_failure) - %span.label.label-danger allowed to fail - - %td - - if project - = link_to project.name_with_namespace, admin_namespace_project_path(project.namespace, project) - - %td - - if build.try(:runner) - = runner_link(build.runner) - - else - .light none - - %td - #{build.stage} / #{build.name} - - %td - - if build.duration - %p.duration - = custom_icon("icon_timer") - = duration_in_numbers(build.finished_at, build.started_at) - - - if build.finished_at - %p.finished-at - = icon("calendar") - %span #{time_ago_with_tooltip(build.finished_at)} - - - if defined?(coverage) && coverage - %td.coverage - - if build.try(:coverage) - #{build.coverage}% - - %td - .pull-right - - if can?(current_user, :read_build, project) && build.artifacts? - = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts', class: 'btn btn-build' do - %i.fa.fa-download - - if can?(current_user, :update_build, build.project) - - if build.active? - = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && build.retryable? - = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do - %i.fa.fa-refresh diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index 3d77634d8fa..26a8846b609 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/builds/index.html.haml @@ -4,26 +4,8 @@ %div{ class: container_class } .top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to admin_builds_path do - All - %span.badge.js-totalbuilds-count= @all_builds.count(:id) - - %li{class: ('active' if @scope == 'pending')} - = link_to admin_builds_path(scope: :pending) do - Pending - %span.badge= number_with_delimiter(@all_builds.pending.count(:id)) - - %li{class: ('active' if @scope == 'running')} - = link_to admin_builds_path(scope: :running) do - Running - %span.badge= number_with_delimiter(@all_builds.running.count(:id)) - - %li{class: ('active' if @scope == 'finished')} - = link_to admin_builds_path(scope: :finished) do - Finished - %span.badge= number_with_delimiter(@all_builds.finished.count(:id)) + - build_path_proc = ->(scope) { admin_builds_path(scope: scope) } + = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope .nav-controls - if @all_builds.running_or_pending.any? @@ -33,23 +15,4 @@ #{(@scope || 'all').capitalize} builds %ul.content-list.builds-content-list - - if @builds.blank? - %li - .nothing-here-block No builds to show - - else - .table-holder - %table.table.builds - %thead - %tr - %th Status - %th Commit - %th Project - %th Runner - %th Name - %th - %th - - - @builds.each do |build| - = render "admin/builds/build", build: build - - = paginate @builds, theme: 'gitlab' + = render "projects/builds/table", builds: @builds, admin: true diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index b74da64f82e..ec40391a3e3 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -1,26 +1,29 @@ -.nav-links.sub-nav - %ul{ class: (container_class) } - = nav_link(controller: :dashboard, html_options: {class: 'home'}) do - = link_to admin_root_path, title: 'Overview' do - %span - Overview - = nav_link(controller: [:admin, :projects]) do - = link_to admin_namespaces_projects_path, title: 'Projects' do - %span - Projects - = nav_link(controller: :users) do - = link_to admin_users_path, title: 'Users' do - %span - Users - = nav_link(controller: :groups) do - = link_to admin_groups_path, title: 'Groups' do - %span - Groups - = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Builds' do - %span - Builds - = nav_link path: ['runners#index', 'runners#show'] do - = link_to admin_runners_path, title: 'Runners' do - %span - Runners += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + = nav_link(controller: :dashboard, html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview' do + %span + Overview + = nav_link(controller: [:admin, :projects]) do + = link_to admin_namespaces_projects_path, title: 'Projects' do + %span + Projects + = nav_link(controller: :users) do + = link_to admin_users_path, title: 'Users' do + %span + Users + = nav_link(controller: :groups) do + = link_to admin_groups_path, title: 'Groups' do + %span + Groups + = nav_link path: 'builds#index' do + = link_to admin_builds_path, title: 'Builds' do + %span + Builds + = nav_link path: ['runners#index', 'runners#show'] do + = link_to admin_runners_path, title: 'Runners' do + %span + Runners diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 452fc25ab07..90798c47d97 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -63,6 +63,11 @@ Reply by email %span.light.pull-right = boolean_to_icon Gitlab::IncomingEmail.enabled? + %p + Container Registry + %span.light.pull-right + = boolean_to_icon Gitlab.config.registry.enabled + .col-md-4 %h4 Components @@ -112,7 +117,7 @@ %h4 Projects .data = link_to admin_namespaces_projects_path do - %h1= number_with_delimiter(Project.count) + %h1= number_with_delimiter(Project.cached_count) %hr = link_to('New Project', new_project_path, class: "btn btn-new") .col-sm-4 diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 5f7fdfdb011..817910f7ddf 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -13,6 +13,8 @@ .col-sm-offset-2.col-sm-10 = render 'shared/allow_request_access', form: f + = render 'groups/group_lfs_settings', f: f + - if @group.new_record? .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index 77a11e49e20..adfa1eaafc9 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -23,4 +23,4 @@ - if group.description.present? .description - = markdown(group.description, pipeline: :description) + = markdown_field(group, :description) diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index bb374694400..0188ed448ce 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -37,6 +37,12 @@ %strong = @group.created_at.to_s(:medium) + %li + %span.light Group Git LFS status: + %strong + = group_lfs_status(@group) + = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') + .panel.panel-default .panel-heading %h3.panel-title diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 448aa953548..d5e6bede36a 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -14,7 +14,7 @@ .col-sm-10 .input-group .input-group-addon.label-color-preview - = f.color_field :color, class: "form-control" + = f.text_field :color, class: "form-control" .help-block Choose any color. %br @@ -28,6 +28,3 @@ .form-actions = f.submit 'Save', class: 'btn btn-save js-save-button' = link_to "Cancel", admin_labels_path, class: 'btn btn-cancel' - -:javascript - new Labels(); diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index f417b2e44a4..be224d66855 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,7 +1,7 @@ %li{id: dom_id(label)} .label-row = render_colored_label(label, tooltip: false) - = markdown(label.description, pipeline: :single_line) + = markdown_field(label, :description) .pull-right = link_to 'Edit', edit_admin_label_path(label), class: 'btn btn-sm' = link_to 'Delete', admin_label_path(label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Delete this label? Are you sure?"} diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 1e755785d90..339cfc613fe 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -87,7 +87,7 @@ - if project.description.present? .description - = markdown(project.description, pipeline: :description) + = markdown_field(project, :description) = paginate @projects, theme: 'gitlab' - else diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index b2c607361b3..6c7c3c48604 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -73,6 +73,12 @@ %span.light last commit: %strong = last_commit(@project) + + %li + %span.light Git LFS status: + %strong + = project_lfs_status(@project) + = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') - else %li %span.light repository: diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index a53876d6757..b760b42fde0 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -5,8 +5,10 @@ %p.prepend-top-default %span - To register a new runner you should enter the following registration token. - With this token the runner will request a unique runner token and use that for future communication. + To register a new Runner you should enter the following registration + token. + With this token the Runner will request a unique Runner token and use + that for future communication. %br Registration token is %code{ id: 'runners-token' } #{current_application_settings.runners_registration_token} @@ -24,27 +26,27 @@ .bs-callout %p - A 'runner' is a process which runs a build. - You can setup as many runners as you need. + A 'Runner' is a process which runs a build. + You can setup as many Runners as you need. %br - Runners can be placed on separate users, servers, and even on your local machine. + Runners can be placed on separate users, servers, even on your local machine. %br %div - %span Each runner can be in one of the following states: + %span Each Runner can be in one of the following states: %ul %li %span.label.label-success shared - \- run builds from all unassigned projects + \- Runner runs builds from all unassigned projects %li %span.label.label-info specific - \- run builds from assigned projects + \- Runner runs builds from assigned projects %li %span.label.label-warning locked - \- runner cannot be assigned to other projects + \- Runner cannot be assigned to other projects %li %span.label.label-danger paused - \- runner will not receive any new builds + \- Runner will not receive any new builds .append-bottom-20.clearfix .pull-left diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 61abfc6ecbe..a5e82e55cc1 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -11,14 +11,14 @@ - if @runner.shared? .bs-callout.bs-callout-success - %h4 This runner will process builds from ALL UNASSIGNED projects + %h4 This Runner will process builds from ALL UNASSIGNED projects %p - If you want runners to build only specific projects, enable them in the table below. + If you want Runners to build only specific projects, enable them in the table below. Keep in mind that this is a one way transition. - else .bs-callout.bs-callout-info - %h4 This runner will process builds only from ASSIGNED projects - %p You can't make this a shared runner. + %h4 This Runner will process builds only from ASSIGNED projects + %p You can't make this a shared Runner. %hr .append-bottom-20 @@ -26,7 +26,7 @@ .row .col-md-6 - %h4 Restrict projects for this runner + %h4 Restrict projects for this Runner - if @runner.projects.any? %table.table.assigned-projects %thead @@ -70,7 +70,7 @@ = paginate @projects .col-md-6 - %h4 Recent builds served by this runner + %h4 Recent builds served by this Runner %table.table.builds.runner-builds %thead %tr diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 8aea67f4497..4ce4eab8753 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -24,6 +24,11 @@ = link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true), data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-xs btn-remove" %td + - if spam_log.submitted_as_ham? + .btn.btn-xs.disabled + Submitted as ham + - else + = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning' - if user && !user.blocked? = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" - else diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 6956e5ab795..bfc6142067a 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -9,12 +9,20 @@ .light-well %h4 CPU .data - %h1= "#{@cpus} cores" + - if @cpus + %h1= "#{@cpus.length} cores" + - else + = icon('warning', class: 'text-warning') + Unable to collect CPU info .col-sm-4 .light-well %h4 Memory .data - %h1= "#{number_to_human_size(@mem_used)} / #{number_to_human_size(@mem_total)}" + - if @memory + %h1= "#{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}" + - else + = icon('warning', class: 'text-warning') + Unable to collect memory info .col-sm-4 .light-well %h4 Disks diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 02efcecc889..fbe3ab912b6 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -1,5 +1,5 @@ - grouped_emojis = awardable.grouped_awards(with_thumbs: inline) -.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) } } +.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } } - awards_sort(grouped_emojis).each do |emoji, awards| %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", class: (award_active_class(awards, current_user)), data: { placement: "bottom", title: award_user_list(awards, current_user) } } = emoji_icon(emoji, sprite: false) diff --git a/app/views/ci/lints/_create.html.haml b/app/views/ci/lints/_create.html.haml index f7875e68b7e..61c7cce20b2 100644 --- a/app/views/ci/lints/_create.html.haml +++ b/app/views/ci/lints/_create.html.haml @@ -16,18 +16,20 @@ %tr %td #{stage.capitalize} Job - #{build[:name]} %td - %pre - = simple_format build[:commands] + %pre= build[:commands] %br %b Tag list: - = build[:tags] + = build[:tag_list].to_a.join(", ") %br %b Refs only: - = build[:only] && build[:only].join(", ") + = @jobs[build[:name].to_sym][:only].to_a.join(", ") %br %b Refs except: - = build[:except] && build[:except].join(", ") + = @jobs[build[:name].to_sym][:except].to_a.join(", ") + %br + %b Environment: + = build[:environment] %br %b When: = build[:when] diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml index 0044d779c31..889086c62b1 100644 --- a/app/views/ci/lints/show.html.haml +++ b/app/views/ci/lints/show.html.haml @@ -1,3 +1,6 @@ +- page_title "CI Lint" +- page_description "Validate your GitLab CI configuration file" + %h2 Check your .gitlab-ci.yml %hr diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml new file mode 100644 index 00000000000..f5222fe631e --- /dev/null +++ b/app/views/dashboard/groups/_empty_state.html.haml @@ -0,0 +1,7 @@ +.groups-empty-state + = custom_icon("icon_empty_groups") + + .text-content + %h4 A group is a collection of several projects. + %p If you organize your projects under a group, it works like a folder. + %p You can manage your group member’s permissions and access to each project in the group. diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index caca91af536..1a679c51774 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -2,9 +2,12 @@ - header_title "Groups", dashboard_groups_path = render 'dashboard/groups_head' -%ul.content-list - - @group_members.each do |group_member| - - group = group_member.group - = render 'shared/groups/group', group: group, group_member: group_member +- if @group_members.empty? + = render 'empty_state' +- else + %ul.content-list + - @group_members.each do |group_member| + - group = group_member.group + = render 'shared/groups/group', group: group, group_member: group_member -= paginate @group_members, theme: 'gitlab' + = paginate @group_members, theme: 'gitlab' diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index d4e7862981c..b2af438ea57 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -4,10 +4,10 @@ = render 'dashboard/snippets_head' .nav-block - .controls - = link_to new_snippet_path, class: "btn btn-new", title: "New Snippet" do + .controls.hidden-xs + = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do = icon('plus') - New Snippet + New snippet .nav-links.snippet-scope-menu %li{ class: ("active" unless params[:scope]) } @@ -34,5 +34,9 @@ %span.badge = current_user.snippets.are_public.count -= render 'snippets/snippets' + .visible-xs + = link_to new_snippet_path, class: "btn btn-new btn-block", title: "New snippet" do + = icon('plus') + New snippet += render 'snippets/snippets' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 98f302d2f93..cc077fad32a 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,6 +1,7 @@ %li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data:{url: todo_target_path(todo)} } + = author_avatar(todo, size: 40) + .todo-item.todo-block - = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:'' .todo-title.title - unless todo.build_failed? = todo_target_state_pill(todo) @@ -18,14 +19,15 @@ (removed) · #{time_ago_with_tooltip(todo.created_at)} - - - if todo.pending? - .todo-actions.pull-right - = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do - Done - = icon('spinner spin') + = todo_due_date(todo) .todo-body .todo-note .md = event_note(todo.body, project: todo.project) + + - if todo.pending? + .todo-actions + = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do + Done + = icon('spinner spin') diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 4e340b6ec16..2a0302638ba 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -28,26 +28,49 @@ .row-content-block.second-block = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form' do .filter-item.inline - = select_tag('project_id', todo_projects_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Project'}) + - if params[:project_id].present? + = hidden_field_tag(:project_id, params[:project_id]) + = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + placeholder: 'Search projects', data: { data: todo_projects_options } }) .filter-item.inline - = users_select_tag(:author_id, selected: params[:author_id], - placeholder: 'Author', class: 'trigger-submit', any_user: "Any Author", first_user: true, current_user: true) + - if params[:author_id].present? + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', + placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author' } }) .filter-item.inline - = select_tag('type', todo_types_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Type'}) + - if params[:type].present? + = hidden_field_tag(:type, params[:type]) + = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', + data: { data: todo_types_options } }) .filter-item.inline.actions-filter - = select_tag('action_id', todo_actions_options, - class: 'select2 trigger-submit', include_blank: true, - data: {placeholder: 'Action'}) + - if params[:action_id].present? + = hidden_field_tag(:action_id, params[:action_id]) + = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', + data: { data: todo_actions_options }}) + .pull-right + .dropdown.inline.prepend-left-10 + %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else + = sort_title_recently_created + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-sort + %li + = link_to todos_filter_path(sort: sort_value_priority) do + = sort_title_priority + = link_to todos_filter_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to todos_filter_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + .prepend-top-default - if @todos.any? .js-todos-options{ data: {per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages} } - @todos.group_by(&:project).each do |group| - .panel.panel-default.panel-small.js-todos-list + .panel.panel-default.panel-small - project = group[0] .panel-heading = link_to project.name_with_namespace, namespace_project_path(project.namespace, project) @@ -57,11 +80,3 @@ = paginate @todos, theme: "gitlab" - else .nothing-here-block You're all done! - -:javascript - new UsersSelect(); - - $('form.filter-form').on('submit', function (event) { - event.preventDefault(); - Turbolinks.visit(this.action + '&' + $(this).serialize()); - }); diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index 73c3a3dd2eb..20cd7b0179d 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -3,9 +3,9 @@ Almost there... %p.lead Please check your email to confirm your account -- if after_sign_up_text.present? +- if current_application_settings.after_sign_up_text.present? .well-confirmation.text-center - = markdown(after_sign_up_text) + = markdown_field(current_application_settings, :after_sign_up_text) %p.confirmation-content.text-center No confirmation email received? Please check your spam folder or .append-bottom-20.prepend-top-20.text-center diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml index 8e81671b7e7..b7d3acac2b1 100644 --- a/app/views/devise/sessions/_new_crowd.html.haml +++ b/app/views/devise/sessions/_new_crowd.html.haml @@ -1,4 +1,4 @@ -= form_tag(user_omniauth_authorize_path("crowd"), id: 'new_crowd_user' ) do += form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user' ) do = text_field_tag :username, nil, {class: "form-control top", placeholder: "Username", autofocus: "autofocus"} = password_field_tag :password, nil, {class: "form-control bottom", placeholder: "Password"} - if devise_mapping.rememberable? diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 689cd6ed665..2ef383960f4 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -1,4 +1,4 @@ -= form_tag(user_omniauth_callback_path(server['provider_name']), id: 'new_ldap_user' ) do += form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user') do = text_field_tag :username, nil, {class: "form-control top", placeholder: "#{server['label']} Login", autofocus: "autofocus"} = password_field_tag :password, nil, {class: "form-control bottom", placeholder: "Password"} - if devise_mapping.rememberable? diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 4debd3d608f..e623f7cff88 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -18,6 +18,5 @@ = f.submit "Verify code", class: "btn btn-save" - if @user.two_factor_u2f_enabled? - %hr - = render "u2f/authenticate" + = render "u2f/authenticate", locals: { params: params, resource: resource, resource_name: resource_name } diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index de18bc2d844..2e7da2747d0 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -5,4 +5,4 @@ - providers.each do |provider| %span.light - has_icon = provider_has_icon?(provider) - = link_to provider_image_tag(provider), user_omniauth_authorize_path(provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true" + = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true" diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index fa1ad9efa73..1411daeb4a6 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -1,6 +1,6 @@ -%tr.notes_holder +- expanded = local_assigns.fetch(:expanded, true) +%tr.notes_holder{class: ('hide' unless expanded)} %td.notes_line{ colspan: 2 } %td.notes_content - %ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "projects/notes/note", collection: discussion.notes, as: :note - = link_to_reply_discussion(discussion) + .content + = render "discussions/notes", discussion: discussion diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 02b159ffd45..3a95a652810 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,8 +7,11 @@ .diff-content.code.js-syntax-highlight %table - - discussion.truncated_diff_lines.each do |line| - = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true - - - if discussion.for_line?(line) - = render "discussions/diff_discussion", discussion: discussion + - discussions = { discussion.original_line_code => discussion } + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: diff_file, + discussions: discussions, + discussion_expanded: true, + plain: true } diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 49702e048aa..077e8e64e5f 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -5,8 +5,17 @@ = link_to user_path(discussion.author) do = image_tag avatar_icon(discussion.author), class: "avatar s40" .timeline-content - .discussion.js-toggle-container{ class: discussion.id } + .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } } .discussion-header + .discussion-actions + = link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do + - if expanded + = icon("chevron-up") + - else + = icon("chevron-down") + + Toggle discussion + = link_to_member(@project, discussion.author, avatar: false) .inline.discussion-headline-light @@ -29,17 +38,11 @@ = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") - .discussion-actions - = link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do - - if expanded - = icon("chevron-up") - - else - = icon("chevron-down") - - Toggle discussion + = render "discussions/headline", discussion: discussion .discussion-body.js-toggle-content{ class: ("hide" unless expanded) } - if discussion.diff_discussion? && discussion.diff_file = render "discussions/diff_with_notes", discussion: discussion - else - = render "discussions/notes", discussion: discussion + .panel.panel-default + = render "discussions/notes", discussion: discussion diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml new file mode 100644 index 00000000000..c1dabeed387 --- /dev/null +++ b/app/views/discussions/_headline.html.haml @@ -0,0 +1,14 @@ +- if discussion.resolved? + .discussion-headline-light.js-discussion-headline + Resolved + - if discussion.resolved_by + by + = link_to_member(@project, discussion.resolved_by, avatar: false) + = time_ago_with_tooltip(discussion.resolved_at, placement: "bottom") +- elsif discussion.last_updated_at != discussion.created_at + .discussion-headline-light.js-discussion-headline + Last updated + - if discussion.last_updated_by + by + = link_to_member(@project, discussion.last_updated_by, avatar: false) + = time_ago_with_tooltip(discussion.last_updated_at, placement: "bottom") diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml new file mode 100644 index 00000000000..7ed09dd1a98 --- /dev/null +++ b/app/views/discussions/_jump_to_next.html.haml @@ -0,0 +1,9 @@ +- discussion = local_assigns.fetch(:discussion, nil) +- if current_user + %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" } + .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" } + %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion", + title: "Jump to next unresolved discussion", + "aria-label" => "Jump to next unresolved discussion", + data: { container: "body" }} + = custom_icon("next_discussion") diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index a2642b839f6..dfdbdf1f969 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,5 +1,16 @@ -.panel.panel-default - .notes{ data: { discussion_id: discussion.id } } - %ul.notes.timeline - = render partial: "projects/notes/note", collection: discussion.notes, as: :note - = link_to_reply_discussion(discussion) +%ul.notes{ data: { discussion_id: discussion.id } } + = render partial: "projects/notes/note", collection: discussion.notes, as: :note + +- if current_user + .discussion-reply-holder + - if discussion.diff_discussion? + - line_type = local_assigns.fetch(:line_type, nil) + + .btn-group-justified.discussion-with-resolve-btn{ role: "group" } + .btn-group{ role: "group" } + = link_to_reply_discussion(discussion, line_type) + = render "discussions/resolve_all", discussion: discussion + - if discussion.for_merge_request? + = render "discussions/jump_to_next", discussion: discussion + - else + = link_to_reply_discussion(discussion) diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index a798c438ea0..f1072ce0feb 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -1,22 +1,21 @@ -%tr.notes_holder +- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?) +%tr.notes_holder{class: ('hide' unless expanded)} - if discussion_left %td.notes_line.old %td.notes_content.parallel.old - %ul.notes{ data: { discussion_id: discussion_left.id } } - = render partial: "projects/notes/note", collection: discussion_left.notes, as: :note - - = link_to_reply_discussion(discussion_left, 'old') + .content{class: ('hide' unless discussion_left.expanded?)} + = render "discussions/notes", discussion: discussion_left, line_type: 'old' - else %td.notes_line.old= "" - %td.notes_content.parallel.old= "" + %td.notes_content.parallel.old + .content - if discussion_right %td.notes_line.new %td.notes_content.parallel.new - %ul.notes{ data: { discussion_id: discussion_right.id } } - = render partial: "projects/notes/note", collection: discussion_right.notes, as: :note - - = link_to_reply_discussion(discussion_right, 'new') + .content{class: ('hide' unless discussion_right.expanded?)} + = render "discussions/notes", discussion: discussion_right, line_type: 'new' - else %td.notes_line.new= "" - %td.notes_content.parallel.new= "" + %td.notes_content.parallel.new + .content diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml new file mode 100644 index 00000000000..f0b61e0f7de --- /dev/null +++ b/app/views/discussions/_resolve_all.html.haml @@ -0,0 +1,10 @@ +- if discussion.for_merge_request? + %resolve-discussion-btn{ ":project-path" => "'#{project_path(discussion.project)}'", + ":discussion-id" => "'#{discussion.id}'", + ":merge-request-id" => discussion.noteable.iid, + ":can-resolve" => discussion.can_resolve?(current_user), + "inline-template" => true } + .btn-group{ role: "group", "v-if" => "showButton" } + %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } + = icon("spinner spin", "v-show" => "loading") + {{ buttonText }} diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml index bfa95ce79a7..9f02a8d2ed9 100644 --- a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml @@ -6,4 +6,4 @@ = form_tag path do %input{:name => "_method", :type => "hidden", :value => "delete"}/ - = submit_tag 'Revoke', onclick: "return confirm('Are you sure?')", class: 'btn btn-link btn-remove btn-sm' + = submit_tag 'Revoke', onclick: "return confirm('Are you sure?')", class: 'btn btn-remove btn-sm' diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 5c318cd3b8b..31fdcc5e21b 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -1,7 +1,7 @@ - if event.visible_to_user?(current_user) .event-item{ class: event_row_class(event) } .event-item-timestamp - #{time_ago_with_tooltip(event.created_at)} + #{time_ago_with_tooltip(event.created_at, skip_js: true)} = cache [event, current_application_settings, "v2.2"] do = author_avatar(event, size: 40) diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 57f6e7e0612..a1b39d9e1a0 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -23,8 +23,8 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - %b.caret - %ul.dropdown-menu + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right %li = link_to explore_groups_path(sort: sort_value_recently_created) do = sort_title_recently_created diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index cd485da5104..4cff14b096b 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -7,8 +7,8 @@ = visibility_level_label(params[:visibility_level].to_i) - else Any - %b.caret - %ul.dropdown-menu + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right %li = link_to filter_projects_path(visibility_level: nil) do Any @@ -27,8 +27,8 @@ = params[:tag] - else Any - %b.caret - %ul.dropdown-menu + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right %li = link_to filter_projects_path(tag: nil) do Any diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml index 6306fe6d0bf..7def9eacdc9 100644 --- a/app/views/explore/snippets/index.html.haml +++ b/app/views/explore/snippets/index.html.haml @@ -8,9 +8,8 @@ .row-content-block - if current_user - .pull-right - = link_to new_snippet_path, class: "btn btn-new", title: "New Snippet" do - New Snippet + = link_to new_snippet_path, class: "btn btn-new btn-wide-on-sm pull-right", title: "New snippet" do + New snippet .oneline Public snippets created by you and other users are listed here diff --git a/app/views/groups/_group_lfs_settings.html.haml b/app/views/groups/_group_lfs_settings.html.haml new file mode 100644 index 00000000000..af57065f0fc --- /dev/null +++ b/app/views/groups/_group_lfs_settings.html.haml @@ -0,0 +1,11 @@ +- if current_user.admin? + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :lfs_enabled do + = f.check_box :lfs_enabled, checked: @group.lfs_enabled? + %strong + Allow projects within this group to use Git LFS + = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') + %br/ + %span.descr This setting can be overridden in each project.
\ No newline at end of file diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index decb89b2fd6..c766370d5a0 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -25,6 +25,8 @@ .col-sm-offset-2.col-sm-10 = render 'shared/allow_request_access', form: f + = render 'group_lfs_settings', f: f + .form-group %hr = f.label :share_with_group_lock, class: 'control-label' do diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml index 9bb9f962177..2fb3190ab11 100644 --- a/app/views/groups/group_members/_new_group_member.html.haml +++ b/app/views/groups/group_members/_new_group_member.html.haml @@ -14,5 +14,14 @@ Read more about role permissions %strong= link_to "here", help_page_path("user/permissions"), class: "vlink" + .form-group + = f.label :expires_at, 'Access expiration date', class: 'control-label' + .col-sm-10 + .clearable-input + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date' + %i.clear-icon.js-clear-input + .help-block + On this date, the user(s) will automatically lose access to this group and all of its projects. + .form-actions = f.submit 'Add users to group', class: "btn btn-create" diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 90f362c052b..f789796e942 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -17,7 +17,7 @@ .panel-heading %strong #{@group.name} group members - %span.badge= @members.size + %span.badge= @members.total_count .controls = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form' do .form-group diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml index da71de4cd1e..3be7ed8432c 100644 --- a/app/views/groups/group_members/update.js.haml +++ b/app/views/groups/group_members/update.js.haml @@ -1,2 +1,3 @@ :plain $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @group_member))}'); + new gl.MemberExpirationDate(); diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index ca6c4326d1c..23d438b2aa1 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -33,8 +33,8 @@ .form-group = f.label :projects, "Projects", class: "control-label" .col-sm-10 - = f.collection_select :project_ids, @group.projects, :id, :name, - { selected: @group.projects.map(&:id) }, multiple: true, class: 'select2' + = f.collection_select :project_ids, @group.projects.non_archived, :id, :name, + { selected: @group.projects.non_archived.pluck(:id) }, multiple: true, class: 'select2' .col-md-6 .form-group diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 53ed4fa991d..fab61f447c2 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -21,9 +21,9 @@ - if @group.description.present? .cover-desc.description - = markdown(@group.description, pipeline: :description) + = markdown_field(@group, :description) -%div{ class: container_class } +%div.groups-header{ class: container_class } .top-area %ul.nav-links %li.active diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index ce4536ebdc6..65842a0479b 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -7,277 +7,284 @@ Keyboard Shortcuts %small = link_to '(Show all)', '#', class: 'js-more-help-button' - .modal-body.shortcuts-cheatsheet - .col-lg-4 - %table.shortcut-mappings - %tbody - %tr - %th - %th Global Shortcuts - %tr - %td.shortcut - .key s - %td Focus Search - %tr - %td.shortcut - .key f - %td Focus Filter - %tr - %td.shortcut - .key ? - %td Show/hide this dialog - %tr - %td.shortcut - - if browser.platform.mac? - .key ⌘ shift p - - else - .key ctrl shift p - %td Toggle Markdown preview - %tr - %td.shortcut - .key - %i.fa.fa-arrow-up - %td Edit last comment (when focused on an empty textarea) - %tbody - %tr - %th - %th Project Files browsing - %tr - %td.shortcut - .key - %i.fa.fa-arrow-up - %td Move selection up - %tr - %td.shortcut - .key - %i.fa.fa-arrow-down - %td Move selection down - %tr - %td.shortcut - .key enter - %td Open Selection - %tbody - %tr - %th - %th Finding Project File - %tr - %td.shortcut - .key - %i.fa.fa-arrow-up - %td Move selection up - %tr - %td.shortcut - .key - %i.fa.fa-arrow-down - %td Move selection down - %tr - %td.shortcut - .key enter - %td Open Selection - %tr - %td.shortcut - .key esc - %td Go back + .modal-body + .row + .col-lg-4 + %table.shortcut-mappings + %tbody + %tr + %th + %th Global Shortcuts + %tr + %td.shortcut + .key s + %td Focus Search + %tr + %td.shortcut + .key f + %td Focus Filter + %tr + %td.shortcut + .key ? + %td Show/hide this dialog + %tr + %td.shortcut + - if browser.platform.mac? + .key ⌘ shift p + - else + .key ctrl shift p + %td Toggle Markdown preview + %tr + %td.shortcut + .key + %i.fa.fa-arrow-up + %td Edit last comment (when focused on an empty textarea) + %tbody + %tr + %th + %th Project Files browsing + %tr + %td.shortcut + .key + %i.fa.fa-arrow-up + %td Move selection up + %tr + %td.shortcut + .key + %i.fa.fa-arrow-down + %td Move selection down + %tr + %td.shortcut + .key enter + %td Open Selection + %tbody + %tr + %th + %th Finding Project File + %tr + %td.shortcut + .key + %i.fa.fa-arrow-up + %td Move selection up + %tr + %td.shortcut + .key + %i.fa.fa-arrow-down + %td Move selection down + %tr + %td.shortcut + .key enter + %td Open Selection + %tr + %td.shortcut + .key esc + %td Go back - .col-lg-4 - %table.shortcut-mappings - %tbody{ class: 'hidden-shortcut project', style: 'display:none' } - %tr - %th - %th Global Dashboard - %tr - %td.shortcut - .key g - .key a - %td - Go to the activity feed - %tr - %td.shortcut - .key g - .key p - %td - Go to projects - %tr - %td.shortcut - .key g - .key i - %td - Go to issues - %tr - %td.shortcut - .key g - .key m - %td - Go to merge requests - %tbody - %tr - %th - %th Project - %tr - %td.shortcut - .key g - .key p - %td - Go to the project's home page - %tr - %td.shortcut - .key g - .key e - %td - Go to the project's activity feed - %tr - %td.shortcut - .key g - .key f - %td - Go to files - %tr - %td.shortcut - .key g - .key c - %td - Go to commits - %tr - %td.shortcut - .key g - .key b - %td - Go to builds - %tr - %td.shortcut - .key g - .key n - %td - Go to network graph - %tr - %td.shortcut - .key g - .key g - %td - Go to graphs - %tr - %td.shortcut - .key g - .key i - %td - Go to issues - %tr - %td.shortcut - .key g - .key m - %td - Go to merge requests - %tr - %td.shortcut - .key g - .key s - %td - Go to snippets - %tr - %td.shortcut - .key t - %td Go to finding file - %tr - %td.shortcut - .key i - %td New issue - .col-lg-4 - %table.shortcut-mappings - %tbody{ class: 'hidden-shortcut network', style: 'display:none' } - %tr - %th - %th Network Graph - %tr - %td.shortcut - .key - %i.fa.fa-arrow-left - \/ - .key h - %td Scroll left - %tr - %td.shortcut - .key - %i.fa.fa-arrow-right - \/ - .key l - %td Scroll right - %tr - %td.shortcut - .key - %i.fa.fa-arrow-up - \/ - .key k - %td Scroll up - %tr - %td.shortcut - .key - %i.fa.fa-arrow-down - \/ - .key j - %td Scroll down - %tr - %td.shortcut - .key - shift - %i.fa.fa-arrow-up - \/ - .key - shift k - %td Scroll to top - %tr - %td.shortcut - .key - shift - %i.fa.fa-arrow-down - \/ - .key - shift j - %td Scroll to bottom - %tbody{ class: 'hidden-shortcut issues', style: 'display:none' } - %tr - %th - %th Issues - %tr - %td.shortcut - .key a - %td Change assignee - %tr - %td.shortcut - .key m - %td Change milestone - %tr - %td.shortcut - .key r - %td Reply (quoting selected text) - %tr - %td.shortcut - .key e - %td Edit issue - %tr - %td.shortcut - .key l - %td Change Label - %tbody{ class: 'hidden-shortcut merge_requests', style: 'display:none' } - %tr - %th - %th Merge Requests - %tr - %td.shortcut - .key a - %td Change assignee - %tr - %td.shortcut - .key m - %td Change milestone - %tr - %td.shortcut - .key r - %td Reply (quoting selected text) - %tr - %td.shortcut - .key e - %td Edit merge request - %tr - %td.shortcut - .key l - %td Change Label + .col-lg-4 + %table.shortcut-mappings + %tbody{ class: 'hidden-shortcut project', style: 'display:none' } + %tr + %th + %th Global Dashboard + %tr + %td.shortcut + .key g + .key a + %td + Go to the activity feed + %tr + %td.shortcut + .key g + .key p + %td + Go to projects + %tr + %td.shortcut + .key g + .key i + %td + Go to issues + %tr + %td.shortcut + .key g + .key m + %td + Go to merge requests + %tbody + %tr + %th + %th Project + %tr + %td.shortcut + .key g + .key p + %td + Go to the project's home page + %tr + %td.shortcut + .key g + .key e + %td + Go to the project's activity feed + %tr + %td.shortcut + .key g + .key f + %td + Go to files + %tr + %td.shortcut + .key g + .key c + %td + Go to commits + %tr + %td.shortcut + .key g + .key b + %td + Go to builds + %tr + %td.shortcut + .key g + .key n + %td + Go to network graph + %tr + %td.shortcut + .key g + .key g + %td + Go to graphs + %tr + %td.shortcut + .key g + .key i + %td + Go to issues + %tr + %td.shortcut + .key g + .key l + %td + Go to issue boards + %tr + %td.shortcut + .key g + .key m + %td + Go to merge requests + %tr + %td.shortcut + .key g + .key s + %td + Go to snippets + %tr + %td.shortcut + .key t + %td Go to finding file + %tr + %td.shortcut + .key i + %td New issue + .col-lg-4 + %table.shortcut-mappings + %tbody{ class: 'hidden-shortcut network', style: 'display:none' } + %tr + %th + %th Network Graph + %tr + %td.shortcut + .key + %i.fa.fa-arrow-left + \/ + .key h + %td Scroll left + %tr + %td.shortcut + .key + %i.fa.fa-arrow-right + \/ + .key l + %td Scroll right + %tr + %td.shortcut + .key + %i.fa.fa-arrow-up + \/ + .key k + %td Scroll up + %tr + %td.shortcut + .key + %i.fa.fa-arrow-down + \/ + .key j + %td Scroll down + %tr + %td.shortcut + .key + shift + %i.fa.fa-arrow-up + \/ + .key + shift k + %td Scroll to top + %tr + %td.shortcut + .key + shift + %i.fa.fa-arrow-down + \/ + .key + shift j + %td Scroll to bottom + %tbody{ class: 'hidden-shortcut issues', style: 'display:none' } + %tr + %th + %th Issues + %tr + %td.shortcut + .key a + %td Change assignee + %tr + %td.shortcut + .key m + %td Change milestone + %tr + %td.shortcut + .key r + %td Reply (quoting selected text) + %tr + %td.shortcut + .key e + %td Edit issue + %tr + %td.shortcut + .key l + %td Change Label + %tbody{ class: 'hidden-shortcut merge_requests', style: 'display:none' } + %tr + %th + %th Merge Requests + %tr + %td.shortcut + .key a + %td Change assignee + %tr + %td.shortcut + .key m + %td Change milestone + %tr + %td.shortcut + .key r + %td Reply (quoting selected text) + %tr + %td.shortcut + .key e + %td Edit merge request + %tr + %td.shortcut + .key l + %td Change Label diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 57601ae9be0..31631887317 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -20,7 +20,7 @@ Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank'}. - if current_application_settings.help_page_text.present? %hr - = markdown(current_application_settings.help_page_text) + = markdown_field(current_application_settings, :help_page_text) %hr diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 85e188d6f8b..d16bd61b779 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -549,4 +549,4 @@ %li wiki page %li help page - You can check how markdown rendered at #{link_to 'Markdown help page', help_page_path("markdown/markdown")}. + You can check how markdown rendered at #{link_to 'Markdown help page', help_page_path("user/markdown")}. diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml index 804ad88468f..8e929538351 100644 --- a/app/views/import/base/create.js.haml +++ b/app/views/import/base/create.js.haml @@ -1,23 +1,4 @@ -- if @already_been_taken - :plain - tr = $("tr#repo_#{@repo_id}") - target_field = tr.find(".import-target") - import_button = tr.find(".btn-import") - origin_target = target_field.text() - project_name = "#{@project_name}" - origin_namespace = "#{@target_namespace}" - target_field.empty() - target_field.append("<p class='alert alert-danger'>This namespace already been taken! Please choose another one</p>") - target_field.append("<input type='text' name='target_namespace' />") - target_field.append("/" + project_name) - target_field.data("project_name", project_name) - target_field.find('input').prop("value", origin_namespace) - import_button.enable().removeClass('is-loading') -- elsif @access_denied - :plain - job = $("tr#repo_#{@repo_id}") - job.find(".import-actions").html("<p class='alert alert-danger'>Access denied! Please verify you can add deploy keys to this repository.</p>") -- elsif @project.persisted? +- if @project.persisted? :plain job = $("tr#repo_#{@repo_id}") job.attr("id", "project_#{@project.id}") diff --git a/app/views/import/base/unauthorized.js.haml b/app/views/import/base/unauthorized.js.haml new file mode 100644 index 00000000000..36f8069c1f7 --- /dev/null +++ b/app/views/import/base/unauthorized.js.haml @@ -0,0 +1,14 @@ +:plain + tr = $("tr#repo_#{@repo_id}") + target_field = tr.find(".import-target") + import_button = tr.find(".btn-import") + origin_target = target_field.text() + project_name = "#{@project_name}" + origin_namespace = "#{@target_namespace.path}" + target_field.empty() + target_field.append("<p class='alert alert-danger'>This namespace has already been taken! Please choose another one.</p>") + target_field.append("<input type='text' name='target_namespace' />") + target_field.append("/" + project_name) + target_field.data("project_name", project_name) + target_field.find('input').prop("value", origin_namespace) + import_button.enable().removeClass('is-loading') diff --git a/app/views/import/bitbucket/deploy_key.js.haml b/app/views/import/bitbucket/deploy_key.js.haml new file mode 100644 index 00000000000..81b34ab5c9d --- /dev/null +++ b/app/views/import/bitbucket/deploy_key.js.haml @@ -0,0 +1,3 @@ +:plain + job = $("tr#repo_#{@repo_id}") + job.find(".import-actions").html("<p class='alert alert-danger'>Access denied! Please verify you can add deploy keys to this repository.</p>") diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index 15dd98077c8..f8b4b107513 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -51,7 +51,7 @@ %td = link_to "#{repo["owner"]}/#{repo["slug"]}", "https://bitbucket.org/#{repo["owner"]}/#{repo["slug"]}", target: "_blank" %td.import-target - = "#{repo["owner"]}/#{repo["slug"]}" + = import_project_target(repo['owner'], repo['slug']) %td.import-actions.job-status = button_tag class: "btn btn-import js-add-to-import" do Import diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index deaaf9af875..4c721d40b55 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -4,10 +4,6 @@ %i.fa.fa-github Import projects from GitHub -%p - %i.fa.fa-warning - To import GitHub pull requests, any pull request source branches that had been deleted are temporarily restored on GitHub. To prevent any connected CI services from being overloaded with dozens of irrelevant branches being created and deleted again, GitHub webhooks are temporarily disabled during the import process, but only if you have admin access to the GitHub repository. - %p.light Select projects you want to import. %hr @@ -49,7 +45,17 @@ %td = github_project_link(repo.full_name) %td.import-target - = repo.full_name + %fieldset.row + .input-group + .project-path.input-group-btn + - if current_user.can_select_namespace? + - selected = params[:namespace_id] || :current_user + - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {} + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 } + - else + = text_field_tag :path, current_user.namespace_path, class: "input-large form-control", tabindex: 1, disabled: true + %span.input-group-addon / + = text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true %td.import-actions.job-status = button_tag class: "btn btn-import js-add-to-import" do Import diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index fcfc6fd37f4..d31fc2e6adb 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -45,7 +45,7 @@ %td = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank" %td.import-target - = repo["path_with_namespace"] + = import_project_target(repo['namespace']['path'], repo['name']) %td.import-actions.job-status = button_tag class: "btn btn-import js-add-to-import" do Import diff --git a/app/views/import/gitorious/status.html.haml b/app/views/import/gitorious/status.html.haml deleted file mode 100644 index ed3afb0ce33..00000000000 --- a/app/views/import/gitorious/status.html.haml +++ /dev/null @@ -1,54 +0,0 @@ -- page_title "Gitorious import" -- header_title "Projects", root_path -%h3.page-title - %i.icon-gitorious.icon-gitorious-big - Import projects from Gitorious.org - -%p.light - Select projects you want to import. -%hr -%p - = button_tag class: "btn btn-import btn-success js-import-all" do - Import all projects - = icon("spinner spin", class: "loading-icon") - -.table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th From Gitorious.org - %th To GitLab - %th Status - %tbody - - @already_added_projects.each do |project| - %tr{id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}"} - %td - = link_to project.import_source, "https://gitorious.org/#{project.import_source}", target: "_blank" - %td - = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project] - %td.job-status - - if project.import_status == 'finished' - %span - %i.fa.fa-check - done - - elsif project.import_status == 'started' - %i.fa.fa-spinner.fa-spin - started - - else - = project.human_import_status_name - - - @repos.each do |repo| - %tr{id: "repo_#{repo.id}"} - %td - = link_to repo.full_name, "https://gitorious.org/#{repo.full_name}", target: "_blank" - %td.import-target - = repo.full_name - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - Import - = icon("spinner spin", class: "loading-icon") - -.js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitorious_path}", import_path: "#{import_gitorious_path}" } } diff --git a/app/views/koding/index.html.haml b/app/views/koding/index.html.haml new file mode 100644 index 00000000000..65887aacbaf --- /dev/null +++ b/app/views/koding/index.html.haml @@ -0,0 +1,6 @@ +.row-content-block.second-block.center + %p + = icon('circle', class: 'cgreen') + Integration is active for + = link_to koding_project_url, target: '_blank' do + #{current_application_settings.koding_url} diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 3612f1ce5c6..baa8036de10 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,8 +1,10 @@ .flash-container.flash-container-page - if alert .flash-alert - = alert + %div{ class: (container_class) } + %span= alert - elsif notice .flash-notice - = notice + %div{ class: (container_class) } + %span= notice diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 351100f3523..67ff4b272b9 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -1,7 +1,7 @@ - project = @target_project || @project -- noteable_class = @noteable.class if @noteable.present? +- noteable_type = @noteable.class if @noteable.present? :javascript - GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}" + GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" GitLab.GfmAutoComplete.cachedData = undefined; GitLab.GfmAutoComplete.setup(); diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index a1a71c2fb33..8aefdcb3d9b 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,10 +1,11 @@ .page-with-sidebar{ class: "#{page_sidebar_class} #{page_gutter_class}" } - .sidebar-wrapper.nicescroll{ class: nav_sidebar_class } + .sidebar-wrapper.nicescroll .sidebar-action-buttons - = link_to '#', class: 'nav-header-btn toggle-nav-collapse', title: "Open/Close" do + .nav-header-btn.toggle-nav-collapse{ title: "Open/Close" } %span.sr-only Toggle navigation = icon('bars') - = link_to '#', class: "nav-header-btn pin-nav-btn has-tooltip #{'is-active' if pinned_nav?} js-nav-pin", title: pinned_nav? ? "Unpin navigation" : "Pin Navigation", data: {placement: 'right', container: 'body'} do + + %div{ class: "nav-header-btn pin-nav-btn has-tooltip #{'is-active' if pinned_nav?} js-nav-pin", title: pinned_nav? ? "Unpin navigation" : "Pin Navigation", data: { placement: 'right', container: 'body' } } %span.sr-only Toggle navigation pinning = icon('fw thumb-tack') @@ -20,10 +21,10 @@ .container-fluid = render "layouts/nav/#{nav}" .content-wrapper{ class: "#{layout_nav_class}" } + = yield :sub_nav = render "layouts/broadcast" = render "layouts/flash" = yield :flash_message - %div{ class: (container_class unless @no_container) } + %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content - .clearfix - = yield + = yield diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index f7580f00159..d7386105b7d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -2,15 +2,18 @@ - label = 'This group' - if controller.controller_path =~ /^projects/ && @project.persisted? - label = 'This project' - +- if @group && @group.persisted? && @group.path + - group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) } +- if @project && @project.persisted? + - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: namespace_project_issues_path(@project.namespace, @project), mr_path: namespace_project_merge_requests_path(@project.namespace, @project) } .search.search-form{class: "#{'has-location-badge' if label.present?}"} = form_tag search_path, method: :get, class: 'navbar-form' do |f| .search-input-container - if label.present? .location-badge= label .search-input-wrap - .dropdown{ data: {url: search_autocomplete_path } } - = search_field_tag "search", nil, placeholder: 'Search', class: "search-input dropdown-menu-toggle", spellcheck: false, tabindex: "1", autocomplete: 'off', data: { toggle: 'dropdown' } + .dropdown{ data: { url: search_autocomplete_path } } + = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url } .dropdown-menu.dropdown-select = dropdown_content do %ul @@ -21,8 +24,9 @@ %i.search-icon %i.clear-icon.js-clear-input - = hidden_field_tag :group_id, @group.try(:id) - = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id' + = hidden_field_tag :group_id, @group.try(:id), class: 'js-search-group-options', data: group_data_attrs + + = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: project_data_attrs - if @project && @project.persisted? - if current_controller?(:issues) @@ -36,31 +40,6 @@ - else = hidden_field_tag :search_code, true - :javascript - gl.projectOptions = gl.projectOptions || {}; - gl.projectOptions["#{j(@project.path)}"] = { - issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", - mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", - name: "#{j(@project.name)}" - }; - - - if @group && @group.persisted? && @group.path - :javascript - gl.groupOptions = gl.groupOptions || {}; - gl.groupOptions["#{j(@group.path)}"] = { - name: "#{j(@group.name)}", - issuesPath: "#{issues_group_path(j(@group.path))}", - mrPath: "#{merge_requests_group_path(j(@group.path))}" - }; - - - :javascript - gl.dashboardOptions = { - issuesPath: "#{issues_dashboard_url}", - mrPath: "#{merge_requests_dashboard_url}" - }; - - - if @snippet || @snippets = hidden_field_tag :snippets, true = hidden_field_tag :repository_ref, @ref diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 33cedaaf2ee..15a94ac23c5 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en"} +%html{ lang: "en", class: "#{page_class}" } = render "layouts/head" %body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}} = Gon::Base.render_data diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 3d28eec84ef..a9a384bd5f3 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -25,8 +25,8 @@ Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki. - - if extra_sign_in_text.present? - = markdown(extra_sign_in_text) + - if current_application_settings.sign_in_text.present? + = markdown_field(current_application_settings, :sign_in_text) %hr .container diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 94c53882623..7faa8bded86 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,5 +1,5 @@ %header.navbar.navbar-fixed-top.navbar-gitlab{ class: nav_header_class } - %div{ class: fluid_layout ? "container-fluid" : "container-fluid" } + %div{ class: "container-fluid" } .header-content %button.side-nav-toggle{ type: 'button', "aria-label" => "Toggle global navigation" } %span.sr-only Toggle navigation @@ -41,7 +41,7 @@ %li.header-user.dropdown = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do = image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar" - %span.caret + = icon('caret-down') .dropdown-menu-nav.dropdown-menu-align-right %ul %li diff --git a/app/views/layouts/koding.html.haml b/app/views/layouts/koding.html.haml new file mode 100644 index 00000000000..22319bba745 --- /dev/null +++ b/app/views/layouts/koding.html.haml @@ -0,0 +1,5 @@ +- page_title "Koding" +- page_description "Koding Dashboard" +- header_title "Koding", koding_path + += render template: "layouts/application" diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 3a14751ea8e..67f558c854b 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -12,6 +12,11 @@ = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do %span Activity + - if koding_enabled? + = nav_link(controller: :koding) do + = link_to koding_path, title: 'Koding' do + %span + Koding = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do = link_to dashboard_groups_path, title: 'Groups' do %span diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index d7d36c84b6c..27ac1760166 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -1,5 +1,5 @@ += render 'layouts/nav/group_settings' .scrolling-tabs-container{ class: nav_control_class } - = render 'layouts/nav/group_settings' .fade-left = icon('angle-left') .fade-right diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml index bf9a7ecb786..75275afc0f3 100644 --- a/app/views/layouts/nav/_group_settings.html.haml +++ b/app/views/layouts/nav/_group_settings.html.haml @@ -1,22 +1,26 @@ - if current_user + - can_admin_group = can?(current_user, :admin_group, @group) - can_edit = can?(current_user, :admin_group, @group) - member = @group.members.find_by(user_id: current_user.id) - can_leave = member && can?(current_user, :destroy_group_member, member) - .controls - .dropdown.group-settings-dropdown - %a.dropdown-new.btn.btn-default#group-settings-button{href: '#', 'data-toggle' => 'dropdown'} - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - = nav_link(path: 'groups#projects') do - = link_to 'Projects', projects_group_path(@group), title: 'Projects' - %li.divider - - if can_edit - %li - = link_to 'Edit Group', edit_group_path(@group) - - if can_leave - %li - = link_to polymorphic_path([:leave, @group, :members]), - data: { confirm: leave_confirmation_message(@group) }, method: :delete, title: 'Leave group' do - Leave Group + - if can_admin_group || can_edit || can_leave + .controls + .dropdown.group-settings-dropdown + %a.dropdown-new.btn.btn-default#group-settings-button{href: '#', 'data-toggle' => 'dropdown'} + = icon('cog') + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right + - if can_admin_group + = nav_link(path: 'groups#projects') do + = link_to 'Projects', projects_group_path(@group), title: 'Projects' + - if can_edit || can_leave + %li.divider + - if can_edit + %li + = link_to 'Edit Group', edit_group_path(@group) + - if can_leave + %li + = link_to polymorphic_path([:leave, @group, :members]), + data: { confirm: leave_confirmation_message(@group) }, method: :delete, title: 'Leave group' do + Leave Group diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 1d3b8fc3683..99a58bbb676 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -47,7 +47,7 @@ Repository - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :environments]) do + = nav_link(controller: [:pipelines, :builds, :environments, :cycle_analytics]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines @@ -65,7 +65,7 @@ Graphs - if project_nav_tab? :issues - = nav_link(controller: [:issues, :labels, :milestones]) do + = nav_link(controller: [:issues, :labels, :milestones, :boards]) do = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do %span Issues @@ -113,3 +113,7 @@ %li.hidden = link_to project_commits_path(@project), title: 'Commits', class: 'shortcuts-commits' do Commits + + -# Shortcut to issue boards + %li.hidden + = link_to 'Issue Boards', namespace_project_boards_path(@project.namespace, @project), title: 'Issue Boards', class: 'shortcuts-issue-boards' diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 52a5bdc1a1b..613b8b7d301 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -26,7 +26,7 @@ %span Protected Branches - - if @project.builds_enabled? + - if @project.feature_available?(:builds, current_user) = nav_link(controller: :runners) do = link_to namespace_project_runners_path(@project.namespace, @project), title: 'Runners' do %span diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index dde2e2889dc..1ec4c3f0c67 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -25,8 +25,8 @@ - if @labels_url adjust your #{link_to 'label subscriptions', @labels_url}. - else - - if @sent_notification && @sent_notification.unsubscribable? - = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) + - if @sent_notification_url + = link_to "unsubscribe", @sent_notification_url from this thread or adjust your notification settings. diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index ee9c0366f2b..277eb71ea73 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -6,16 +6,13 @@ - content_for :scripts_body_top do - project = @target_project || @project - if @project_wiki && @page - - markdown_preview_path = namespace_project_wiki_markdown_preview_path(project.namespace, project, @page.slug) + - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug) - else - - markdown_preview_path = markdown_preview_namespace_project_path(project.namespace, project) + - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - if current_user :javascript window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - window.markdown_preview_path = "#{markdown_preview_path}"; - -- content_for :scripts_body do - = render "layouts/init_auto_complete" if current_user + window.preview_markdown_path = "#{preview_markdown_path}"; - content_for :header_content do .js-dropdown-menu-projects diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index fc64c98038b..ca5c2f2688c 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -3,3 +3,5 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> Assignee: <%= @issue.assignee_name %> + +<%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml new file mode 100644 index 00000000000..4f3d36bd9ca --- /dev/null +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -0,0 +1,12 @@ +%p + You have been mentioned in an issue. + +- if current_application_settings.email_author_in_body + %div + #{link_to @issue.author_name, user_url(@issue.author)} wrote: +-if @issue.description + = markdown(@issue.description, pipeline: :email, author: @issue.author) + +- if @issue.assignee_id.present? + %p + Assignee: #{@issue.assignee_name} diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb new file mode 100644 index 00000000000..457e94b4800 --- /dev/null +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -0,0 +1,7 @@ +You have been mentioned in an issue. + +Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> +Author: <%= @issue.author_name %> +Assignee: <%= @issue.assignee_name %> + +<%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml new file mode 100644 index 00000000000..32aedb9e6b9 --- /dev/null +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -0,0 +1,15 @@ +%p + You have been mentioned in Merge Request #{@merge_request.to_reference} + +- if current_application_settings.email_author_in_body + %div + #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: +%p.details + != merge_path_description(@merge_request, '→') + +- if @merge_request.assignee_id.present? + %p + Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} + +-if @merge_request.description + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb new file mode 100644 index 00000000000..5bf0282e097 --- /dev/null +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -0,0 +1,9 @@ +You have been mentioned in Merge Request <%= @merge_request.to_reference %> + +<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %> + +<%= merge_path_description(@merge_request, 'to') %> +Author: <%= @merge_request.author_name %> +Assignee: <%= @merge_request.assignee_name %> + +<%= @merge_request.description %> diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index d4aad8d1862..3c8f178ac77 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -6,3 +6,5 @@ New Merge Request <%= @merge_request.to_reference %> Author: <%= @merge_request.author_name %> Assignee: <%= @merge_request.assignee_name %> +<%= @merge_request.description %> + diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c161ecc3463..c0c07d65daa 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -75,8 +75,7 @@ - blob = diff_file.blob - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) %table.code.white - - diff_file.highlighted_diff_lines.each do |line| - = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true + = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true } - else No preview for this file type %br diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml new file mode 100644 index 00000000000..522421b7cc3 --- /dev/null +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -0,0 +1,2 @@ +%p + All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name} diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb new file mode 100644 index 00000000000..b0d380af8fc --- /dev/null +++ b/app/views/notify/resolved_all_discussions_email.text.erb @@ -0,0 +1,3 @@ +All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %> + +<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %> diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 57d16d29158..c80f22457b4 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -70,7 +70,7 @@ = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do Disconnect - else - = link_to user_omniauth_authorize_path(provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do Connect %hr - if current_user.can_change_username? diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index a42b3b8eb38..93187873501 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,4 +1,5 @@ - page_title "SSH Keys" += render 'profiles/head' .row.prepend-top-default .col-lg-3.profile-settings-sidebar diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 71ac367830d..05a2ea67aa2 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -7,6 +7,10 @@ = page_title %p You can generate a personal access token for each application you use that needs access to the GitLab API. + %p + You can also use personal access tokens to authenticate against Git over HTTP. + They are the only accepted password when you have Two-Factor Authentication (2FA) enabled. + .col-lg-9 - if flash[:personal_access_token] diff --git a/app/views/profiles/preferences/update.js.erb b/app/views/profiles/preferences/update.js.erb index 4433cab7782..8966dd3fd86 100644 --- a/app/views/profiles/preferences/update.js.erb +++ b/app/views/profiles/preferences/update.js.erb @@ -4,9 +4,9 @@ $('body').addClass('<%= user_application_theme %>') // Toggle container-fluid class if ('<%= current_user.layout %>' === 'fluid') { - $('.content-wrapper').find('.container-fluid').removeClass('container-limited') + $('.content-wrapper .container-fluid').removeClass('container-limited') } else { - $('.content-wrapper').find('.container-fluid').addClass('container-limited') + $('.content-wrapper .container-fluid').addClass('container-limited') } // Re-enable the "Save" button diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index d9fa74fad90..578af9fe98d 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -87,6 +87,9 @@ = f.label :location, 'Location', class: "label-light" = f.text_field :location, class: "form-control" .form-group + = f.label :organization, 'Organization', class: "label-light" + = f.text_field :organization, class: "form-control" + .form-group = f.label :bio, class: "label-light" = f.text_area :bio, rows: 4, class: "form-control", maxlength: 250 %span.help-block Tell us about yourself in fewer than 250 characters. diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 366f1fed35b..03ac739ade5 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -60,13 +60,38 @@ two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser. .col-lg-9 - %p - - if @registration_key_handles.present? - = icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab." - if @u2f_registration.errors.present? = form_errors(@u2f_registration) = render "u2f/register" + %hr + + %h5 U2F Devices (#{@u2f_registrations.length}) + + - if @u2f_registrations.present? + .table-responsive + %table.table.table-bordered.u2f-registrations + %colgroup + %col{ width: "50%" } + %col{ width: "30%" } + %col{ width: "20%" } + %thead + %tr + %th Name + %th Registered On + %th + %tbody + - @u2f_registrations.each do |registration| + %tr + %td= registration.name.presence || "<no name set>" + %td= registration.created_at.to_date.to_s(:medium) + %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." } + + - else + .settings-message.text-center + You don't have any U2F devices registered yet. + + - if two_factor_skippable? :javascript var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>"; diff --git a/app/views/profiles/update_username.js.haml b/app/views/profiles/update_username.js.haml index 249680bcab6..de1337a2a24 100644 --- a/app/views/profiles/update_username.js.haml +++ b/app/views/profiles/update_username.js.haml @@ -1,6 +1,6 @@ - if @user.valid? :plain - new Flash("Username sucessfully changed", "notice") + new Flash("Username successfully changed", "notice") - else :plain new Flash("Username change failed - #{@user.errors.full_messages.first}", "alert") diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index ac50ce83f6a..d011e51e696 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,13 +1,16 @@ -.nav-block.activity-filter-block - - if current_user - .controls - = link_to namespace_project_path(@project.namespace, @project, format: :atom, private_token: current_user.private_token), title: "Feed", class: 'btn rss-btn' do - %i.fa.fa-rss +- @no_container = true - = render 'shared/event_filter' +%div{ class: container_class } + .nav-block.activity-filter-block + - if current_user + .controls + = link_to namespace_project_path(@project.namespace, @project, format: :atom, private_token: current_user.private_token), title: "Feed", class: 'btn rss-btn' do + = icon('rss') -.content_list.project-activity{:"data-href" => activity_project_path(@project)} -= spinner + = render 'shared/event_filter' + + .content_list.project-activity{:"data-href" => activity_project_path(@project)} + = spinner :javascript var activity = new Activities(); diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 51f74f3b7ce..5590198a20e 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -9,7 +9,7 @@ .project-home-desc - if @project.description.present? - = markdown(@project.description, pipeline: :description) + = markdown_field(@project, :description) - if forked_from_project = @project.forked_from_project %p @@ -24,6 +24,3 @@ .project-clone-holder = render "shared/clone_panel" - -:javascript - new Star(); diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 3c6b931f41a..1c3bccccb5c 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -1,6 +1,6 @@ - if event = last_push_event - if show_last_push_widget?(event) - .row-content-block.top-block.clear-block.hidden-xs + .row-content-block.top-block.hidden-xs.white %div{ class: container_class } .event-last-push .event-last-push-text diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index 19b4249374b..80053dd501b 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -1,11 +1,14 @@ -%fieldset.builds-feature - %h5.prepend-top-0 - Merge Requests - .form-group - .checkbox - = f.label :only_allow_merge_if_build_succeeds do - = f.check_box :only_allow_merge_if_build_succeeds - %strong Only allow merge requests to be merged if the build succeeds - .help-block - Builds need to be configured to enable this feature. - = link_to icon('question-circle'), help_page_path('workflow/merge_requests', anchor: 'only-allow-merge-requests-to-be-merged-if-the-build-succeeds') +.merge-requests-feature + %fieldset.builds-feature + %hr + %h5.prepend-top-0 + Merge Requests + .form-group + .checkbox + = f.label :only_allow_merge_if_build_succeeds do + = f.check_box :only_allow_merge_if_build_succeeds + %strong Only allow merge requests to be merged if the build succeeds + %br + %span.descr + Builds need to be configured to enable this feature. + = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_build_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-build-succeeds') diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 413477a2d3a..cb97181b9e1 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,8 +1,12 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f - = f.text_area attr, class: classes, placeholder: placeholder + = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands } - else = text_area_tag attr, nil, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } = icon('compress') + +- content_for :scripts_body do + = render "layouts/init_auto_complete" if current_user && (@target_project || @project) diff --git a/app/views/projects/badges/badge.svg.erb b/app/views/projects/badges/badge.svg.erb new file mode 100644 index 00000000000..a5fef4fc56f --- /dev/null +++ b/app/views/projects/badges/badge.svg.erb @@ -0,0 +1,36 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="<%= badge.width %>" height="20"> + <linearGradient id="b" x2="0" y2="100%"> + <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> + <stop offset="1" stop-opacity=".1"/> + </linearGradient> + + <mask id="a"> + <rect width="<%= badge.width %>" height="20" rx="3" fill="#fff"/> + </mask> + + <g mask="url(#a)"> + <path fill="<%= badge.key_color %>" + d="M0 0 h<%= badge.key_width %> v20 H0 z"/> + <path fill="<%= badge.value_color %>" + d="M<%= badge.key_width %> 0 h<%= badge.value_width %> v20 H<%= badge.key_width %> z"/> + <path fill="url(#b)" + d="M0 0 h<%= badge.width %> v20 H0 z"/> + </g> + + <g fill="#fff" text-anchor="middle"> + <g font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="<%= badge.key_text_anchor %>" y="15" fill="#010101" fill-opacity=".3"> + <%= badge.key_text %> + </text> + <text x="<%= badge.key_text_anchor %>" y="14"> + <%= badge.key_text %> + </text> + <text x="<%= badge.value_text_anchor %>" y="15" fill="#010101" fill-opacity=".3"> + <%= badge.value_text %> + </text> + <text x="<%= badge.value_text_anchor %>" y="14"> + <%= badge.value_text %> + </text> + </g> + </g> +</svg> diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 377665b096f..5a98e258b22 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -11,7 +11,7 @@ %small= number_to_human_size @blob.size .file-actions = render "projects/blob/actions" - .file-content.blame.code.js-syntax-highlight + .table-responsive.file-content.blame.code.js-syntax-highlight %table - current_line = 1 - @blame_groups.each do |blame_group| @@ -19,6 +19,7 @@ %td.blame-commit .commit - commit = blame_group[:commit] + = author_avatar(commit, size: 36) .commit-row-title %strong = link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark" diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index ff379bafb26..d4f59764a70 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -21,10 +21,17 @@ = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.hidden = dropdown_tag("Choose a GitLab CI Yaml template", options: { toggle_class: 'js-gitlab-ci-yml-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } ) + = button_tag class: 'soft-wrap-toggle btn', type: 'button' do + %span.no-wrap + = custom_icon('icon_no_wrap') + No wrap + %span.soft-wrap + = custom_icon('icon_soft_wrap') + Soft wrap .encoding-selector = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' - .file-content.code + .file-editor.code %pre.js-edit-mode-pane#editor #{params[:content] || local_assigns[:blob_data]} - if local_assigns[:path] .js-edit-mode-pane#preview.hide diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml index 18caddabd39..4c356d1f07f 100644 --- a/app/views/projects/blob/_image.html.haml +++ b/app/views/projects/blob/_image.html.haml @@ -1,9 +1,15 @@ .file-content.image_file - if blob.svg? - - # We need to scrub SVG but we cannot do so in the RawController: it would - - # be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - if blob.size_within_svg_limits? + - # We need to scrub SVG but we cannot do so in the RawController: it would + - # be wrong/strange if RawController modified the data. + - blob.load_all_data!(@repository) + - blob = sanitize_svg(blob) + %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - else + .nothing-here-block + The SVG could not be displayed as it is too large, you can + #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank')} + instead. - else %img{src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path))} diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml index 5926d181ba3..a79ae53c780 100644 --- a/app/views/projects/blob/diff.html.haml +++ b/app/views/projects/blob/diff.html.haml @@ -1,20 +1,30 @@ - if @lines.present? + - line_class = diff_view == :inline ? '' : diff_view - if @form.unfold? && @form.since != 1 && !@form.bottom? - %tr.line_holder - = render "projects/diffs/match_line", { line: @match_line, - line_old: @form.since, line_new: @form.since, bottom: false, new_file: false } + %tr.line_holder{ class: line_class } + = diff_match_line @form.since, @form.since, text: @match_line, view: diff_view - @lines.each_with_index do |line, index| - line_new = index + @form.since - line_old = line_new - @form.offset - %tr.line_holder{ id: line_old } - %td.old_line.diff-line-num{ data: { linenumber: line_old } } - = link_to raw(line_old), "##{line_old}" - %td.new_line.diff-line-num{ data: { linenumber: line_old } } - = link_to raw(line_new) , "##{line_old}" - %td.line_content.noteable_line==#{' ' * @form.indent}#{line} + - line_content = capture do + %td.line_content.noteable_line{ class: line_class }==#{' ' * @form.indent}#{line} + %tr.line_holder{ id: line_old, class: line_class } + - case diff_view + - when :inline + %td.old_line.diff-line-num{ data: { linenumber: line_old } } + %a{href: "##{line_old}", data: { linenumber: line_old }} + %td.new_line.diff-line-num{ data: { linenumber: line_new } } + %a{href: "##{line_new}", data: { linenumber: line_new }} + = line_content + - when :parallel + %td.old_line.diff-line-num{data: { linenumber: line_old }} + = link_to raw(line_old), "##{line_old}" + = line_content + %td.new_line.diff-line-num{data: { linenumber: line_new }} + = link_to raw(line_new), "##{line_new}" + = line_content - if @form.unfold? && @form.bottom? && @form.to < @blob.loc - %tr.line_holder{ id: @form.to } - = render "projects/diffs/match_line", { line: @match_line, - line_old: @form.to, line_new: @form.to, bottom: true, new_file: false } + %tr.line_holder{ id: @form.to, class: line_class } + = diff_match_line @form.to, @form.to, text: @match_line, view: diff_view, bottom: true diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index b1c9895f43e..680e95ac6b5 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,4 +1,13 @@ - page_title "Edit", @blob.path, @ref +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') + = page_specific_javascript_tag('blob_edit/blob_edit_bundle.js') + +- if @conflict + .alert.alert-danger + Someone edited the file the same time you did. Please check out + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank" + and make sure your changes will not unintentionally remove theirs. .file-editor %ul.nav-links.no-bottom.js-edit-mode @@ -10,15 +19,10 @@ = link_to '#preview', 'data-preview-url' => namespace_project_preview_blob_path(@project.namespace, @project, @id) do = editing_preview_title(@blob.name) - = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form') do + = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do = render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data = render 'shared/new_commit_form', placeholder: "Update #{@blob.name}" - - = hidden_field_tag 'last_commit', @last_commit + = hidden_field_tag 'last_commit_sha', @last_commit_sha = hidden_field_tag 'content', '', id: "file-content" = hidden_field_tag 'from_merge_request_id', params[:from_merge_request_id] = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id) - -:javascript - blob = new EditBlob(gon.relative_url_root + "#{Gitlab::Application.config.assets.prefix}", "#{@blob.language.try(:ace_mode)}") - new NewCommitForm($('.js-edit-blob-form')) diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index c952bc7e5db..b6ed9518c48 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,17 +1,16 @@ - page_title "New File", @path.presence, @ref +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') + = page_specific_javascript_tag('blob_edit/blob_edit_bundle.js') %h3.page-title New File .file-editor - = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-new-blob-form js-quick-submit js-requires-input') do + = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do = render 'projects/blob/editor', ref: @ref = render 'shared/new_commit_form', placeholder: "Add new file" = hidden_field_tag 'content', '', id: 'file-content' = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_tree_path(@project.namespace, @project, @id) - -:javascript - blob = new EditBlob(gon.relative_url_root + "#{Gitlab::Application.config.assets.prefix}") - new NewCommitForm($('.js-new-blob-form')) diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml new file mode 100644 index 00000000000..97eb952eff1 --- /dev/null +++ b/app/views/projects/boards/components/_blank_state.html.haml @@ -0,0 +1,15 @@ +%board-blank-state{ "inline-template" => true, + "v-if" => "list.id == 'blank'" } + .board-blank-state + %p + Add the following default lists to your Issue Board with one click: + %ul.board-blank-state-list + %li{ "v-for" => "label in predefinedLabels" } + %span.label-color{ ":style" => "{ backgroundColor: label.color } " } + {{ label.title }} + %p + Starting out with the default set of lists will get you right on the way to making the most of your board. + %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" } + Add default lists + %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" } + Nevermind, I'll use my own diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml new file mode 100644 index 00000000000..ba1502c97b6 --- /dev/null +++ b/app/views/projects/boards/components/_board.html.haml @@ -0,0 +1,78 @@ +%board{ "inline-template" => true, + "v-cloak" => true, + "v-for" => "list in state.lists | orderBy 'position'", + "v-ref:board" => true, + ":list" => "list", + ":disabled" => "disabled", + ":issue-link-base" => "issueLinkBase", + "track-by" => "_uid" } + .board{ ":class" => "{ 'is-draggable': !list.preset }", + ":data-id" => "list.id" } + .board-inner + %header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" } + %h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" } + {{ list.title }} + .board-issue-count-holder.pull-right.clearfix{ "v-if" => "list.type !== 'blank'" } + %span.board-issue-count.pull-left{ ":class" => "{ 'has-btn': list.type !== 'done' }" } + {{ list.issuesSize }} + - if can?(current_user, :admin_issue, @project) + %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", + "@click" => "showNewIssueForm", + "v-if" => "list.type !== 'done'", + "aria-label" => "Add an issue", + "title" => "Add an issue", + data: { placement: "top", container: "body" } } + = icon("plus") + - if can?(current_user, :admin_list, @project) + %board-delete{ "inline-template" => true, + ":list" => "list", + "v-if" => "!list.preset && list.id" } + %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + = icon("trash") + %board-list{ "inline-template" => true, + "v-if" => "list.type !== 'blank'", + ":list" => "list", + ":issues" => "list.issues", + ":loading" => "list.loading", + ":disabled" => "disabled", + ":show-issue-form.sync" => "showIssueForm", + ":issue-link-base" => "issueLinkBase" } + .board-list-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + - if can? current_user, :create_issue, @project + %board-new-issue{ "inline-template" => true, + ":list" => "list", + ":show-issue-form.sync" => "showIssueForm", + "v-show" => "list.type !== 'done' && showIssueForm" } + .card.board-new-issue-form + %form{ "@submit" => "submit($event)" } + .flash-container{ "v-if" => "error" } + .flash-alert + An error occured. Please try again. + %label.label-light{ ":for" => "list.id + '-title'" } + Title + %input.form-control{ type: "text", + "v-model" => "title", + "v-el:input" => true, + ":id" => "list.id + '-title'" } + .clearfix.prepend-top-10 + %button.btn.btn-success.pull-left{ type: "submit", + ":disabled" => "title === ''", + "v-el:submit-button" => true } + Submit issue + %button.btn.btn-default.pull-right{ type: "button", + "@click" => "cancel" } + Cancel + %ul.board-list{ "v-el:list" => true, + "v-show" => "!loading", + ":data-board" => "list.id", + ":class" => "{ 'is-smaller': showIssueForm }" } + = render "projects/boards/components/card" + %li.board-list-count.text-center{ "v-if" => "showCount" } + = icon("spinner spin", "v-show" => "list.loadingMore" ) + %span{ "v-if" => "list.issues.length === list.issuesSize" } + Showing all issues + %span{ "v-else" => true } + Showing {{ list.issues.length }} of {{ list.issuesSize }} issues + - if can?(current_user, :admin_list, @project) + = render "projects/boards/components/blank_state" diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml new file mode 100644 index 00000000000..d8f16022407 --- /dev/null +++ b/app/views/projects/boards/components/_card.html.haml @@ -0,0 +1,33 @@ +%board-card{ "inline-template" => true, + "v-for" => "issue in issues | orderBy 'priority'", + "v-ref:issue" => true, + ":index" => "$index", + ":list" => "list", + ":issue" => "issue", + ":issue-link-base" => "issueLinkBase", + ":disabled" => "disabled", + "track-by" => "id" } + %li.card{ ":class" => "{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id }", + ":index" => "index" } + %h4.card-title + = icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential") + %a{ ":href" => "issueLinkBase + '/' + issue.id", + ":title" => "issue.title" } + {{ issue.title }} + .card-footer + %span.card-number{ "v-if" => "issue.id" } + = precede '#' do + {{ issue.id }} + %button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels", + type: "button", + "v-if" => "(!list.label || label.id !== list.label.id)", + "@click" => "filterByLabel(label, $event)", + ":style" => "{ backgroundColor: label.color, color: label.textColor }", + ":title" => "label.description", + data: { container: 'body' } } + {{ label.title }} + %a.has-tooltip{ ":href" => "'/' + issue.assignee.username", + ":title" => "'Assigned to ' + issue.assignee.name", + "v-if" => "issue.assignee", + data: { container: 'body' } } + %img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 } diff --git a/app/views/projects/boards/index.html.haml b/app/views/projects/boards/index.html.haml new file mode 100644 index 00000000000..885f8e34b55 --- /dev/null +++ b/app/views/projects/boards/index.html.haml @@ -0,0 +1,16 @@ +- @no_container = true +- @content_class = "issue-boards-content" +- page_title "Boards" + +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('boards/boards_bundle.js') + = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test? + += render "projects/issues/head" + += render 'shared/issuable/filter', type: :boards + +.boards-list#board-app{ "v-cloak" => true, data: board_data } + .boards-app-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + = render "projects/boards/components/board" diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml new file mode 100644 index 00000000000..885f8e34b55 --- /dev/null +++ b/app/views/projects/boards/show.html.haml @@ -0,0 +1,16 @@ +- @no_container = true +- @content_class = "issue-boards-content" +- page_title "Boards" + +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('boards/boards_bundle.js') + = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test? + += render "projects/issues/head" + += render 'shared/issuable/filter', type: :boards + +.boards-list#board-app{ "v-cloak" => true, data: board_data } + .boards-app-loading.text-center{ "v-if" => "loading" } + = icon("spinner spin") + = render "projects/boards/components/board" diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 4bd85061240..4480b2f22c3 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -3,10 +3,11 @@ - diverging_commit_counts = @repository.diverging_commit_counts(branch) - number_commits_behind = diverging_commit_counts[:behind] - number_commits_ahead = diverging_commit_counts[:ahead] +- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) %li(class="js-branch-#{branch.name}") %div - = link_to namespace_project_tree_path(@project.namespace, @project, branch.name) do - %span.item-title.str-truncated= branch.name + = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated' do + = branch.name - if branch.name == @repository.root_ref %span.label.label-primary default @@ -19,16 +20,18 @@ %i.fa.fa-lock protected .controls.hidden-xs - - if create_mr_button?(@repository.root_ref, branch.name) + - if merge_project && create_mr_button?(@repository.root_ref, branch.name) = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do Merge Request - if branch.name != @repository.root_ref - = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: 'btn btn-default', method: :post, title: "Compare" do + = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do Compare - - if can_remove_branch?(@project, branch.name) - = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete branch", method: :delete, data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?", container: 'body' }, remote: true do + = render 'projects/buttons/download', project: @project, ref: branch.name + + - if can?(current_user, :push_code, @project) + = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), class: "btn btn-remove remove-row has-tooltip #{can_remove_branch?(@project, branch.name) ? '' : 'disabled'}", title: "Delete branch", method: :delete, data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?", container: 'body' }, remote: true do = icon("trash-o") - if branch.name != @repository.root_ref diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index e889f29c816..84f38575e84 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -15,7 +15,7 @@ %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} %span.light = projects_sort_options_hash[@sort] - %b.caret + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right %li = link_to filter_branches_path(sort: sort_value_name) do diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index a8bc53c2849..966633f1f89 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -1,3 +1,6 @@ +- builds = @build.pipeline.builds.latest.to_a +- statuses = ["failed", "pending", "running", "canceled", "success", "skipped"] + %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default Build @@ -5,104 +8,138 @@ %a.gutter-toggle.pull-right.js-sidebar-build-toggle{ href: "#" } = icon('angle-double-right') - if @build.coverage - .block.block-first + .block.coverage .title Test coverage %p.build-detail-row #{@build.coverage}% - - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) - .block{ class: ("block-first" if !@build.coverage) } + .blocks-container + - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) + .block{ class: ("block-first" if !@build.coverage) } + .title + Build artifacts + - if @build.artifacts_expired? + %p.build-detail-row + The artifacts were removed + #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + %p.build-detail-row + The artifacts will be removed in + %span.js-artifacts-remove= @build.artifacts_expire_at + + - if @build.artifacts? + .btn-group.btn-group-justified{ role: :group } + - if @build.artifacts_expire_at + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + Keep + + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Download + + - if @build.artifacts_metadata? + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Browse + + .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title - Build artifacts - - if @build.artifacts_expired? + Build details + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post + - if @build.merge_request %p.build-detail-row - The artifacts were removed - #{time_ago_with_tooltip(@build.artifacts_expire_at)} - - elsif @build.artifacts_expire_at + %span.build-light-text Merge Request: + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) + - if @build.duration %p.build-detail-row - The artifacts will be removed in - %span.js-artifacts-remove= @build.artifacts_expire_at - - - if @build.artifacts? - .btn-group.btn-group-justified{ role: :group } - - if @build.artifacts_expire_at - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do - Keep - - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Download - - - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Browse - - .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } - .title - Build details - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post - - if @build.merge_request - %p.build-detail-row - %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) - - if @build.duration - %p.build-detail-row - %span.build-light-text Duration: - = time_interval_in_words(@build.duration) - - if @build.finished_at - %p.build-detail-row - %span.build-light-text Finished: - #{time_ago_with_tooltip(@build.finished_at)} - - if @build.erased_at + %span.build-light-text Duration: + = time_interval_in_words(@build.duration) + - if @build.finished_at + %p.build-detail-row + %span.build-light-text Finished: + #{time_ago_with_tooltip(@build.finished_at)} + - if @build.erased_at + %p.build-detail-row + %span.build-light-text Erased: + #{time_ago_with_tooltip(@build.erased_at)} %p.build-detail-row - %span.build-light-text Erased: - #{time_ago_with_tooltip(@build.erased_at)} - %p.build-detail-row - %span.build-light-text Runner: - - if @build.runner && current_user && current_user.admin - = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) - - elsif @build.runner - \##{@build.runner.id} - .btn-group.btn-group-justified{ role: :group } - - if @build.has_trace? - = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - - if @build.active? - = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - - if can?(current_user, :update_build, @project) && @build.erasable? - = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), - class: "btn btn-sm btn-default", method: :post, - data: { confirm: "Are you sure you want to erase this build?" } do - Erase - - - if @build.trigger_request - .build-widget - %h4.title - Trigger - - %p - %span.build-light-text Token: - #{@build.trigger_request.trigger.short_token} - - - if @build.trigger_request.variables + %span.build-light-text Runner: + - if @build.runner && current_user && current_user.admin + = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) + - elsif @build.runner + \##{@build.runner.id} + .btn-group.btn-group-justified{ role: :group } + - if @build.has_trace_file? + = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' + - if @build.active? + = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post + - if can?(current_user, :update_build, @project) && @build.erasable? + = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), + class: "btn btn-sm btn-default", method: :post, + data: { confirm: "Are you sure you want to erase this build?" } do + Erase + + - if @build.trigger_request + .build-widget + %h4.title + Trigger + %p - %span.build-light-text Variables: + %span.build-light-text Token: + #{@build.trigger_request.trigger.short_token} + - if @build.trigger_request.variables + %p + %button.btn.group.btn-group-justified.reveal-variables Reveal Variables - - @build.trigger_request.variables.each do |key, value| - %code - #{key}=#{value} - .block - .title - Commit title - %p.build-light-text.append-bottom-0 - #{@build.pipeline.git_commit_title} + - @build.trigger_request.variables.each do |key, value| + .hide.js-build + .js-build-variable= key + .js-build-value= value - - if @build.tags.any? .block .title - Tags - - @build.tag_list.each do |tag| - %span.label.label-primary - = tag + Commit title + %p.build-light-text.append-bottom-0 + #{@build.pipeline.git_commit_title} + + - if @build.tags.any? + .block + .title + Tags + - @build.tag_list.each do |tag| + %span.label.label-primary + = tag + + - if @build.pipeline.stages.many? + .dropdown.build-dropdown + .title Stage + %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'} + %span.stage-selection More + = icon('caret-down') + %ul.dropdown-menu + - @build.pipeline.stages.each do |stage| + %li + %a.stage-item= stage + + .builds-container + - statuses.each do |build_status| + - builds.select{|build| build.status == build_status}.each do |build| + .build-job{class: ('active' if build == @build), data: {stage: build.stage}} + = link_to namespace_project_build_path(@project.namespace, @project, build) do + = icon('arrow-right') + = ci_icon_for_status(build.status) + %span + - if build.name + = build.name + - else + = build.id + + - if @build.retried? + %li.active + %a + Build ##{@build.id} + · + %i.fa.fa-warning + This build was retried. diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/builds/_table.html.haml new file mode 100644 index 00000000000..f3747ba2a21 --- /dev/null +++ b/app/views/projects/builds/_table.html.haml @@ -0,0 +1,24 @@ +- admin = local_assigns.fetch(:admin, false) + +- if builds.blank? + %div + .nothing-here-block No builds to show +- else + .table-holder + %table.table.builds + %thead + %tr + %th Status + %th Build + - if admin + %th Project + %th Runner + %th Stage + %th Name + %th + %th Coverage + %th + + = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin } + + = paginate builds, theme: 'gitlab' diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml index 2af625f69cd..06070f12bbd 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -4,30 +4,8 @@ %div{ class: container_class } .top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to project_builds_path(@project) do - All - %span.badge.js-totalbuilds-count - = number_with_delimiter(@all_builds.count(:id)) - - %li{class: ('active' if @scope == 'pending')} - = link_to project_builds_path(@project, scope: :pending) do - Pending - %span.badge - = number_with_delimiter(@all_builds.pending.count(:id)) - - %li{class: ('active' if @scope == 'running')} - = link_to project_builds_path(@project, scope: :running) do - Running - %span.badge - = number_with_delimiter(@all_builds.running.count(:id)) - - %li{class: ('active' if @scope == 'finished')} - = link_to project_builds_path(@project, scope: :finished) do - Finished - %span.badge - = number_with_delimiter(@all_builds.finished.count(:id)) + - build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) } + = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope .nav-controls - if can?(current_user, :update_build, @project) @@ -41,24 +19,5 @@ = link_to ci_lint_path, class: 'btn btn-default' do %span CI Lint - %ul.content-list.builds-content-list - - if @builds.blank? - %li - .nothing-here-block No builds to show - - else - .table-holder - %table.table.builds - %thead - %tr - %th Status - %th Commit - %th Stage - %th Name - %th - - if @project.build_coverage_enabled? - %th Coverage - %th - - = render @builds, commit_sha: true, ref: true, stage: true, allow_retry: true, coverage: @project.build_coverage_enabled? - - = paginate @builds, theme: 'gitlab' + %div.content-list.builds-content-list + = render "table", builds: @builds, project: @project diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index 4421f3b9562..e4d41288aa6 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -5,26 +5,6 @@ .build-page = render "header" - - builds = @build.pipeline.builds.latest.to_a - - if builds.size > 1 - %ul.nav-links.no-top.no-bottom - - builds.each do |build| - %li{class: ('active' if build == @build) } - = link_to namespace_project_build_path(@project.namespace, @project, build) do - = ci_icon_for_status(build.status) - %span - - if build.name - = build.name - - else - = build.id - - - if @build.retried? - %li.active - %a - Build ##{@build.id} - · - %i.fa.fa-warning - This build was retried. - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning @@ -67,4 +47,10 @@ = render "sidebar" :javascript - new Build("#{namespace_project_build_url(@project.namespace, @project, @build)}", "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", "#{@build.status}", "#{trace_with_state[:state]}") + new Build({ + page_url: "#{namespace_project_build_url(@project.namespace, @project, @build)}", + build_url: "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", + build_status: "#{@build.status}", + build_stage: "#{@build.stage}", + state1: "#{trace_with_state[:state]}" + }) diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 58f43ecb5d5..9089586a89d 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,4 +1,42 @@ -- unless @project.empty_repo? - - if can? current_user, :download_code, @project - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: @ref, format: 'zip'), class: 'btn has-tooltip', data: {container: "body"}, rel: 'nofollow', title: "Download ZIP" do - = icon('download') +- if !project.empty_repo? && can?(current_user, :download_code, project) + %span{class: 'hidden-xs hidden-sm'} + .dropdown.inline + %button.btn{ 'data-toggle' => 'dropdown' } + = icon('download') + = icon("caret-down") + %span.sr-only + Select Archive Format + %ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' } + %li.dropdown-header Source code + %li + = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow' do + %i.fa.fa-download + %span Download zip + %li + = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow' do + %i.fa.fa-download + %span Download tar.gz + %li + = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.bz2'), rel: 'nofollow' do + %i.fa.fa-download + %span Download tar.bz2 + %li + = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar'), rel: 'nofollow' do + %i.fa.fa-download + %span Download tar + + - pipeline = project.pipelines.latest_successful_for(ref) + - if pipeline + - artifacts = pipeline.builds.latest.with_artifacts + - if artifacts.any? + %li.dropdown-header Artifacts + - unless pipeline.latest? + - latest_pipeline = project.pipeline_for(ref) + %li + .unclickable= ci_status_for_statuseable(latest_pipeline) + %li.dropdown-header Previous Artifacts + - artifacts.each do |job| + %li + = link_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{ref}/download", job: job.name), rel: 'nofollow' do + %i.fa.fa-download + %span Download '#{job.name}' diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index ca907077c2b..6cd9b98a706 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -1,7 +1,8 @@ - if current_user - .btn-group + .dropdown.inline.project-dropdown %a.btn.dropdown-toggle{href: '#', "data-toggle" => "dropdown"} = icon('plus') + = icon("caret-down") %ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown - can_create_issue = can?(current_user, :create_issue, @project) - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index d78888e9fe4..29d549a60f5 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -3,12 +3,12 @@ - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn has-tooltip' do = custom_icon('icon_fork') - Fork + %span Fork - else - = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork project", class: 'btn has-tooltip' do + = link_to new_namespace_project_fork_path(@project.namespace, @project), title: 'Fork project', class: 'btn has-tooltip' do = custom_icon('icon_fork') - Fork + %span Fork %div.count-with-arrow %span.arrow - = link_to namespace_project_forks_path(@project.namespace, @project), class: "count" do + = link_to namespace_project_forks_path(@project.namespace, @project), title: 'Forks', class: 'count has-tooltip' do = @project.forks_count diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml new file mode 100644 index 00000000000..fdc80d44253 --- /dev/null +++ b/app/views/projects/buttons/_koding.html.haml @@ -0,0 +1,7 @@ +- if koding_enabled? && current_user && can_push_branch?(@project, @project.default_branch) + - if @repository.koding_yml + = link_to koding_project_url(@project), class: 'btn', target: '_blank' do + Run in IDE (Koding) + - else + = link_to add_koding_stack_path(@project), class: 'btn' do + Set Up Koding diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 71cf5582a4c..311583037e5 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,10 +1,10 @@ - if current_user = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do - if current_user.starred?(@project) - = icon('star fw') + = icon('star') %span.starred Unstar - else - = icon('star-o fw') + = icon('star-o') %span Star %div.count-with-arrow %span.arrow @@ -13,7 +13,7 @@ - else = link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do - = icon('star fw') + = icon('star') Star %div.count-with-arrow %span.arrow diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 91081435220..9248adfde80 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -1,3 +1,11 @@ +- admin = local_assigns.fetch(:admin, false) +- ref = local_assigns.fetch(:ref, nil) +- commit_sha = local_assigns.fetch(:commit_sha, nil) +- retried = local_assigns.fetch(:retried, false) +- stage = local_assigns.fetch(:stage, false) +- coverage = local_assigns.fetch(:coverage, false) +- allow_retry = local_assigns.fetch(:allow_retry, false) + %tr.build.commit %td.status - if can?(current_user, :read_build, build) @@ -5,54 +13,58 @@ - else = ci_status_with_icon(build.status) - %td - .branch-commit - - if can?(current_user, :read_build, build) - = link_to namespace_project_build_url(build.project.namespace, build.project, build) do - %span ##{build.id} - - else - %span ##{build.id} + %td.branch-commit + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do + %span.build-link ##{build.id} + - else + %span.build-link ##{build.id} - - if defined?(ref) && ref - - if build.ref - .icon-container - = build.tag? ? icon('tag') : icon('code-fork') - = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" - - else - .light none + - if ref + - if build.ref .icon-container - = custom_icon("icon_commit") + = build.tag? ? icon('tag') : icon('code-fork') + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" + - else + .light none + .icon-container + = custom_icon("icon_commit") - - if defined?(commit_sha) && commit_sha - = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace" + - if commit_sha + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace" - - if build.stuck? - = icon('warning', class: 'text-warning has-tooltip', title: 'Build is stuck. Check runners.') - - if defined?(retried) && retried - = icon('warning', class: 'text-warning has-tooltip', title: 'Build was retried.') + - if build.stuck? + = icon('warning', class: 'text-warning has-tooltip', title: 'Build is stuck. Check runners.') + - if retried + = icon('warning', class: 'text-warning has-tooltip', title: 'Build was retried.') - .label-container - - if build.tags.any? - - build.tags.each do |tag| - %span.label.label-primary - = tag - - if build.try(:trigger_request) - %span.label.label-info triggered - - if build.try(:allow_failure) - %span.label.label-danger allowed to fail - - if defined?(retried) && retried - %span.label.label-warning retried - - if build.manual? - %span.label.label-info manual + .label-container + - if build.tags.any? + - build.tags.each do |tag| + %span.label.label-primary + = tag + - if build.try(:trigger_request) + %span.label.label-info triggered + - if build.try(:allow_failure) + %span.label.label-danger allowed to fail + - if retried + %span.label.label-warning retried + - if build.manual? + %span.label.label-info manual - - if defined?(runner) && runner + - if admin + %td + - if build.project + = link_to build.project.name_with_namespace, admin_namespace_project_path(build.project.namespace, build.project) + + - if admin %td - if build.try(:runner) = runner_link(build.runner) - else .light none - - if defined?(stage) && stage + - if stage %td = build.stage @@ -63,14 +75,15 @@ - if build.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(build.finished_at, build.started_at) + = duration_in_numbers(build.duration) + - if build.finished_at %p.finished-at = icon("calendar") %span #{time_ago_with_tooltip(build.finished_at)} - - if defined?(coverage) && coverage - %td.coverage + %td.coverage + - if coverage - if build.try(:coverage) #{build.coverage}% @@ -83,10 +96,10 @@ - if build.active? = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - - elsif defined?(allow_retry) && allow_retry + - elsif allow_retry - if build.retryable? = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do = icon('repeat') - - elsif build.playable? + - elsif build.playable? && !admin = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do - = icon('play') + = custom_icon('icon_play') diff --git a/app/views/projects/ci/builds/_build_pipeline.html.haml b/app/views/projects/ci/builds/_build_pipeline.html.haml new file mode 100644 index 00000000000..017d3ff6af2 --- /dev/null +++ b/app/views/projects/ci/builds/_build_pipeline.html.haml @@ -0,0 +1,14 @@ +- is_playable = subject.playable? && can?(current_user, :update_build, @project) +- if is_playable + = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, title: 'Play' do + = render_status_with_link('build', 'play') + .ci-status-text= subject.name +- elsif can?(current_user, :read_build, @project) + = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject) do + %span.ci-status-icon + = render_status_with_link('build', subject.status) + .ci-status-text= subject.name +- else + %span.ci-status-icon + = render_status_with_link('build', subject.status) + = ci_icon_for_status(subject.status) diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index 558c35553da..36eadbd2bf1 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -1,62 +1,64 @@ - status = pipeline.status +- show_commit = local_assigns.fetch(:show_commit, true) +- show_branch = local_assigns.fetch(:show_branch, true) + %tr.commit %td.commit-link - = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do - = ci_status_with_icon(status) - - - %td - .branch-commit - = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do - %span ##{pipeline.id} - - if pipeline.ref - .icon-container - = pipeline.tag? ? icon('tag') : icon('code-fork') - = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name" - .icon-container - = custom_icon("icon_commit") - = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace" - - if pipeline.latest? - %span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest - - if pipeline.triggered? - %span.label.label-primary triggered - - if pipeline.yaml_errors.present? - %span.label.label-danger.has-tooltip{ title: "#{pipeline.yaml_errors}" } yaml invalid - - if pipeline.builds.any?(&:stuck?) - %span.label.label-warning stuck + = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) do + - if defined?(status_icon_only) && status_icon_only + = ci_icon_for_status(status) + - else + = ci_status_with_icon(status) + %td.branch-commit + = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) do + %span ##{pipeline.id} + - if pipeline.ref && show_branch + .icon-container + = pipeline.tag? ? icon('tag') : icon('code-fork') + = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace branch-name" + - if show_commit + .icon-container + = custom_icon("icon_commit") + = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "commit-id monospace" + - if pipeline.latest? + %span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest + - if pipeline.triggered? + %span.label.label-primary triggered + - if pipeline.yaml_errors.present? + %span.label.label-danger.has-tooltip{ title: "#{pipeline.yaml_errors}" } yaml invalid + - if pipeline.builds.any?(&:stuck?) + %span.label.label-warning stuck - %p.commit-title - - if commit = pipeline.commit - = author_avatar(commit, size: 20) - = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "commit-row-message" - - else - Cant find HEAD commit for this branch + %p.commit-title + - if commit = pipeline.commit + = author_avatar(commit, size: 20) + = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(pipeline.project.namespace, pipeline.project, commit.id), class: "commit-row-message" + - else + Cant find HEAD commit for this branch - - stages_status = pipeline.statuses.latest.stages_status - - stages.each do |stage| - %td.stage-cell + - stages_status = pipeline.statuses.relevant.latest.stages_status + %td.stage-cell + - stages.each do |stage| - status = stages_status[stage] - tooltip = "#{stage.titleize}: #{status || 'not found'}" - if status - = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do - = ci_icon_for_status(status) - - else - .light.has-tooltip{ title: tooltip } - \- + .stage-container + = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do + = ci_icon_for_status(status) %td - - if pipeline.started_at && pipeline.finished_at + - if pipeline.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(pipeline.finished_at, pipeline.started_at) + = duration_in_numbers(pipeline.duration) - if pipeline.finished_at %p.finished-at = icon("calendar") - #{time_ago_with_tooltip(pipeline.finished_at)} + #{time_ago_with_tooltip(pipeline.finished_at, short_format: false, skip_js: true)} - %td.pipeline-actions - .controls.hidden-xs.pull-right + %td.pipeline-actions.hidden-xs + .controls.pull-right - artifacts = pipeline.builds.latest.with_artifacts_not_expired - actions = pipeline.manual_actions - if artifacts.present? || actions.any? @@ -64,31 +66,31 @@ - if actions.any? .btn-group %a.dropdown-toggle.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'} - = icon("play") - %b.caret + = custom_icon('icon_play') + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - actions.each do |build| %li - = link_to play_namespace_project_build_path(@project.namespace, @project, build), method: :post, rel: 'nofollow' do - = icon("play") + = link_to play_namespace_project_build_path(pipeline.project.namespace, pipeline.project, build), method: :post, rel: 'nofollow' do + = custom_icon('icon_play') %span= build.name.humanize - if artifacts.present? .btn-group %a.dropdown-toggle.btn.btn-default.build-artifacts{type: 'button', 'data-toggle' => 'dropdown'} = icon("download") - %b.caret + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - artifacts.each do |build| %li - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, build), rel: 'nofollow' do + = link_to download_namespace_project_build_artifacts_path(pipeline.project.namespace, pipeline.project, build), rel: 'nofollow' do = icon("download") %span Download '#{build.name}' artifacts - - if can?(current_user, :update_pipeline, @project) + - if can?(current_user, :update_pipeline, pipeline.project) .cancel-retry-btns.inline - if pipeline.retryable? - = link_to retry_namespace_project_pipeline_path(@project.namespace, @project, pipeline.id), class: 'btn has-tooltip', title: "Retry", method: :post do + = link_to retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn has-tooltip', title: "Retry", method: :post do = icon("repeat") - if pipeline.cancelable? - = link_to cancel_namespace_project_pipeline_path(@project.namespace, @project, pipeline.id), class: 'btn btn-remove has-tooltip', title: "Cancel", method: :post do + = link_to cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-remove has-tooltip', title: "Cancel", method: :post do = icon("remove") diff --git a/app/views/projects/commit/_builds.html.haml b/app/views/projects/commit/_builds.html.haml index a508382578a..b7087749428 100644 --- a/app/views/projects/commit/_builds.html.haml +++ b/app/views/projects/commit/_builds.html.haml @@ -1,2 +1,2 @@ -- @pipelines.each do |pipeline| +- @ci_pipelines.each do |pipeline| = render "pipeline", pipeline: pipeline, pipeline_details: true diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index d9b800a4ded..e4cd55b9f7a 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -17,7 +17,9 @@ .form-group.branch = label_tag 'target_branch', target_label, class: 'control-label' .col-sm-10 - = select_tag "target_branch", project_branches, class: "select2 select2-sm js-target-branch" + = hidden_field_tag :target_branch, @project.default_branch, id: 'target_branch' + = dropdown_tag(@project.default_branch, options: { title: "Switch branch", filter: true, placeholder: "Search branches", toggle_class: 'js-project-refs-dropdown js-target-branch dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "target_branch", selected: @project.default_branch, target_branch: @project.default_branch, refs_url: namespace_project_branches_path(@project.namespace, @project), submit_form_on_click: false }}) + - if can?(current_user, :push_code, @project) .js-create-merge-request-container .checkbox diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index 935433306ea..cbfd99ca448 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -3,6 +3,11 @@ = link_to namespace_project_commit_path(@project.namespace, @project, @commit.id) do Changes %span.badge= @diffs.size + - if can?(current_user, :read_pipeline, @project) + = nav_link(path: 'commit#pipelines') do + = link_to pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id) do + Pipelines + %span.badge= @ci_pipelines.count = nav_link(path: 'commit#builds') do = link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id) do Builds diff --git a/app/views/projects/commit/_ci_stage.html.haml b/app/views/projects/commit/_ci_stage.html.haml index 9d925cacc0d..6bb900e3fc1 100644 --- a/app/views/projects/commit/_ci_stage.html.haml +++ b/app/views/projects/commit/_ci_stage.html.haml @@ -8,8 +8,8 @@ - if stage = stage.titleize - = render statuses.latest.ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, allow_retry: true - = render statuses.retried.ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, retried: true + = render statuses.latest_ci_stages, coverage: @project.build_coverage_enabled?, stage: false, ref: false, allow_retry: true + = render statuses.retried_ci_stages, coverage: @project.build_coverage_enabled?, stage: false, ref: false, retried: true %tr %td{colspan: 10} diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 3ad866bb2f1..6c82a4e5600 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -14,7 +14,7 @@ .dropdown.inline %a.btn.btn-default.dropdown-toggle{ data: { toggle: "dropdown" } } %span.hidden-xs Options - %span.caret.commit-options-dropdown-caret + = icon('caret-down', class: ".commit-options-dropdown-caret") %ul.dropdown-menu.dropdown-menu-align-right %li.visible-xs-block.visible-sm-block = link_to namespace_project_tree_path(@project.namespace, @project, @commit) do @@ -24,6 +24,8 @@ = revert_commit_link(@commit, namespace_project_commit_path(@project.namespace, @project, @commit.id), has_tooltip: false) %li.clearfix = cherry_pick_commit_link(@commit, namespace_project_commit_path(@project.namespace, @project, @commit.id), has_tooltip: false) + %li.clearfix + = link_to "Tag", new_namespace_project_tag_path(@project.namespace, @project, ref: @commit) %li.divider %li.dropdown-header Download @@ -56,17 +58,17 @@ = pluralize(@commit.pipelines.count, 'pipeline') = link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id), class: "ci-status-link ci-status-icon-#{@commit.status}" do = ci_icon_for_status(@commit.status) - = ci_label_for_status(@commit.status) - - if @commit.pipelines.duration - in - = time_interval_in_words @commit.pipelines.duration + %span.ci-status-label + = ci_label_for_status(@commit.status) + in + = time_interval_in_words @commit.pipelines.total_duration .commit-box.content-block %h3.commit-title - = markdown escape_once(@commit.title), pipeline: :single_line, author: @commit.author + = markdown(@commit.title, pipeline: :single_line, author: @commit.author) - if @commit.description.present? %pre.commit-description - = preserve(markdown(escape_once(@commit.description), pipeline: :single_line, author: @commit.author)) + = preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author)) :javascript $(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}"); diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index 540689f4a61..288c06d9b67 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -1,27 +1,47 @@ -.row-content-block.build-content.middle-block - .pull-right - - if can?(current_user, :update_pipeline, pipeline.project) - - if pipeline.builds.latest.failed.any?(&:retryable?) - = link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post +.pipeline-graph-container + .row-content-block.build-content.middle-block.pipeline-actions + .pull-right + .btn.btn-grouped.btn-white.toggle-pipeline-btn + %span.toggle-btn-text Hide + %span pipeline graph + %span.caret + - if can?(current_user, :update_pipeline, pipeline.project) + - if pipeline.builds.latest.failed.any?(&:retryable?) + = link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post - - if pipeline.builds.running_or_pending.any? - = link_to "Cancel running", cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-grouped btn-danger', method: :post + - if pipeline.builds.running_or_pending.any? + = link_to "Cancel running", cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-grouped btn-danger', method: :post + + .oneline.clearfix + - if defined?(pipeline_details) && pipeline_details + Pipeline + = link_to "##{pipeline.id}", namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: "monospace" + with + = pluralize pipeline.statuses.count(:id), "build" + - if pipeline.ref + for + = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace" + - if defined?(link_to_commit) && link_to_commit + for commit + = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "monospace" + - if pipeline.duration + in + = time_interval_in_words pipeline.duration + + .row-content-block.build-content.middle-block.pipeline-graph.hidden + .pipeline-visualization + %ul.stage-column-list + - stages = pipeline.stages_with_latest_statuses + - stages.each do |stage, statuses| + %li.stage-column + .stage-name + %a{name: stage} + - if stage + = stage.titleize + .builds-container + %ul + = render "projects/commit/pipeline_stage", statuses: statuses - .oneline.clearfix - - if defined?(pipeline_details) && pipeline_details - Pipeline - = link_to "##{pipeline.id}", namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: "monospace" - with - = pluralize pipeline.statuses.count(:id), "build" - - if pipeline.ref - for - = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace" - - if defined?(link_to_commit) && link_to_commit - for commit - = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "monospace" - - if pipeline.duration - in - = time_interval_in_words pipeline.duration - if pipeline.yaml_errors.present? .bs-callout.bs-callout-danger @@ -46,5 +66,5 @@ - if pipeline.project.build_coverage_enabled? %th Coverage %th - - pipeline.statuses.stages.each do |stage| - = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.where(stage: stage) + - pipeline.statuses.relevant.stages.each do |stage| + = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.relevant.where(stage: stage) diff --git a/app/views/projects/commit/_pipeline_stage.html.haml b/app/views/projects/commit/_pipeline_stage.html.haml new file mode 100644 index 00000000000..289aa5178b1 --- /dev/null +++ b/app/views/projects/commit/_pipeline_stage.html.haml @@ -0,0 +1,14 @@ +- status_groups = statuses.group_by(&:group_name) +- status_groups.each do |group_name, grouped_statuses| + - if grouped_statuses.one? + - status = grouped_statuses.first + - is_playable = status.playable? && can?(current_user, :update_build, @project) + %li.build{ class: ("playable" if is_playable) } + .curve + .build-content + = render "projects/#{status.to_partial_path}_pipeline", subject: status + - else + %li.build + .curve + .dropdown.inline.build-content + = render "projects/commit/pipeline_status_group", name: group_name, subject: grouped_statuses diff --git a/app/views/projects/commit/_pipeline_status_group.html.haml b/app/views/projects/commit/_pipeline_status_group.html.haml new file mode 100644 index 00000000000..5d0d5ba0262 --- /dev/null +++ b/app/views/projects/commit/_pipeline_status_group.html.haml @@ -0,0 +1,13 @@ +- group_status = CommitStatus.where(id: subject).status +%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } + %span.ci-status-icon + = render_status_with_link('build', group_status) + %span.ci-status-text + = name + %span.badge= subject.size +.dropdown-menu.grouped-pipeline-dropdown + .arrow + %ul + - subject.each do |status| + %li + = render "projects/#{status.to_partial_path}_pipeline", subject: status diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml new file mode 100644 index 00000000000..998812793a2 --- /dev/null +++ b/app/views/projects/commit/_pipelines_list.haml @@ -0,0 +1,14 @@ +%ul.content-list.pipelines + - if pipelines.blank? + %li + .nothing-here-block No pipelines to show + - else + .table-holder + %table.table.builds + %tbody + %th Status + %th Pipeline + %th Stages + %th + %th + = render pipelines, commit_sha: true, stage: true, allow_retry: true, stages: pipelines.stages, status_icon_only: true, show_commit: false diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml new file mode 100644 index 00000000000..d85d6729a81 --- /dev/null +++ b/app/views/projects/commit/pipelines.html.haml @@ -0,0 +1,7 @@ +- page_title "Pipelines", "#{@commit.title} (#{@commit.short_id})", "Commits" + +.prepend-top-default + = render "commit_box" + += render "ci_menu" += render "pipelines_list", pipelines: @ci_pipelines diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index fd888f41b1e..fb48aef0559 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -7,7 +7,7 @@ - cache_key = [project.path_with_namespace, commit.id, current_application_settings, note_count] - cache_key.push(commit.status) if commit.status -= cache(cache_key) do += cache(cache_key, expires_in: 1.day) do %li.commit.js-toggle-container{ id: "commit-#{commit.short_id}" } = author_avatar(commit, size: 36) @@ -33,7 +33,7 @@ - if commit.description? %pre.commit-row-description.js-toggle-content - = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) + = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author)) .commit-row-info = commit_author_link(commit, avatar: false, size: 24) diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml index 61152649907..80763ce67ca 100644 --- a/app/views/projects/commits/_head.html.haml +++ b/app/views/projects/commits/_head.html.haml @@ -1,30 +1,28 @@ -.scrolling-tabs-container.sub-nav-scroll - .fade-left - = icon('angle-left') - .fade-right - = icon('angle-right') - .nav-links.sub-nav.scrolling-tabs - %ul{ class: (container_class) } - = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do - = link_to project_files_path(@project) do - Files += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do + = link_to project_files_path(@project) do + Files - = nav_link(controller: [:commit, :commits]) do - = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do - Commits + = nav_link(controller: [:commit, :commits]) do + = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do + Commits - = nav_link(controller: %w(network)) do - = link_to namespace_project_network_path(@project.namespace, @project, current_ref) do - Network + = nav_link(controller: %w(network)) do + = link_to namespace_project_network_path(@project.namespace, @project, current_ref) do + Network - = nav_link(controller: :compare) do - = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do - Compare + = nav_link(controller: :compare) do + = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do + Compare - = nav_link(html_options: {class: branches_tab_class}) do - = link_to namespace_project_branches_path(@project.namespace, @project) do - Branches + = nav_link(html_options: {class: branches_tab_class}) do + = link_to namespace_project_branches_path(@project.namespace, @project) do + Branches - = nav_link(controller: [:tags, :releases]) do - = link_to namespace_project_tags_path(@project.namespace, @project) do - Tags + = nav_link(controller: [:tags, :releases]) do + = link_to namespace_project_tags_path(@project.namespace, @project) do + Tags diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 9a44ba94970..876c8002627 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -5,7 +5,8 @@ - if current_user = auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, format: :atom, private_token: current_user.private_token), title: "#{@project.name}:#{@ref} commits") -= render "head" += content_for :sub_nav do + = render "head" %div{ class: container_class } .row-content-block.second-block.content-component-block diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index d79336f5a60..76b68c544aa 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -1,17 +1,22 @@ = form_tag namespace_project_compare_index_path(@project.namespace, @project), method: :post, class: 'form-inline js-requires-input' do .clearfix - if params[:to] && params[:from] - = link_to icon('exchange'), {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'} - .form-group.dropdown.compare-form-group.js-compare-from-dropdown + .compare-switch-container + = link_to icon('exchange'), {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'} + .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown .input-group.inline-input-group %span.input-group-addon from - = text_field_tag :from, params[:from], class: "form-control js-compare-dropdown", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from].presence } + = hidden_field_tag :from, params[:from] + = button_tag type: 'button', class: "form-control compare-dropdown-toggle js-compare-dropdown", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do + .dropdown-toggle-text= params[:from] || 'Select branch/tag' = render "ref_dropdown" - = "..." - .form-group.dropdown.compare-form-group.js-compare-to-dropdown + .compare-ellipsis ... + .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .input-group.inline-input-group %span.input-group-addon to - = text_field_tag :to, params[:to], class: "form-control js-compare-dropdown", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to].presence } + = hidden_field_tag :to, params[:to] + = button_tag type: 'button', class: "form-control compare-dropdown-toggle js-compare-dropdown", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do + .dropdown-toggle-text= params[:to] || 'Select branch/tag' = render "ref_dropdown" = button_tag "Compare", class: "btn btn-create commits-compare-btn" diff --git a/app/views/projects/compare/_ref_dropdown.html.haml b/app/views/projects/compare/_ref_dropdown.html.haml index c604c6d0135..27d928c87a0 100644 --- a/app/views/projects/compare/_ref_dropdown.html.haml +++ b/app/views/projects/compare/_ref_dropdown.html.haml @@ -1,4 +1,5 @@ .dropdown-menu.dropdown-menu-selectable = dropdown_title "Select branch/tag" + = dropdown_filter "Filter by branch/tag" = dropdown_content = dropdown_loading diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index e9ff8e90dd5..45be6581cfc 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -4,7 +4,7 @@ %div{ class: container_class } .sub-header-block - Compare branches, tags or commit ranges. + Compare Git revisions. %br Fill input field with commit id like %code.label-branch 4eedf23 diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml new file mode 100644 index 00000000000..7f346df8797 --- /dev/null +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -0,0 +1,59 @@ +- @no_container = true +- page_title "Cycle Analytics" += render "projects/pipelines/head" + +#cycle-analytics{class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project)}} + + .bordered-box.landing.content-block{"v-if" => "!isHelpDismissed"} + = icon('times', class: 'dismiss-icon', "@click": "dismissLanding()") + .row + .col-sm-3.col-xs-12.svg-container + = custom_icon('icon_cycle_analytics_splash') + .col-sm-8.col-xs-12.inner-content + %h4 + Introducing Cycle Analytics + %p + Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. + + = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' + + = icon("spinner spin", "v-show" => "isLoading") + + .wrapper{"v-show" => "!isLoading && !hasError"} + .panel.panel-default + .panel-heading + Pipeline Health + + .content-block + .container-fluid + .row + .col-sm-3.col-xs-12.column{"v-for" => "item in analytics.summary"} + %h3.header {{item.value}} + %p.text {{item.title}} + + .col-sm-3.col-xs-12.column + .dropdown.inline.js-ca-dropdown + %button.dropdown-menu-toggle{"data-toggle" => "dropdown", :type => "button"} + %span.dropdown-label Last 30 days + %i.fa.fa-chevron-down + %ul.dropdown-menu.dropdown-menu-align-right + %li + %a{'href' => "#", 'data-value' => '30'} + Last 30 days + %li + %a{'href' => "#", 'data-value' => '90'} + Last 90 days + + .bordered-box + %ul.content-list + %li{"v-for" => "item in analytics.stats"} + .container-fluid + .row + .col-xs-8.title-col + %p.title + {{item.title}} + %p.text + {{item.description}} + .col-xs-4.value-col + %span + {{item.value}} diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index f70dba224fa..22c4a75d213 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -1,17 +1,23 @@ - if can?(current_user, :create_deployment, deployment) && deployment.deployable .pull-right + + - external_url = deployment.environment.external_url + - if external_url + = link_to external_url, target: '_blank', class: 'btn external-url' do + = icon('external-link') + - actions = deployment.manual_actions - if actions.present? - .btn-group.inline - .btn-group - %a.dropdown-toggle.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'} - = icon("play") - %b.caret + .inline + .dropdown + %a.dropdown-new.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'} + = custom_icon('icon_play') + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - actions.each do |action| %li = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do - = icon("play") + = custom_icon('icon_play') %span= action.name.humanize - if local_assigns.fetch(:allow_rollback, false) diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 0f9d9512d88..28813babd7b 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -1,12 +1,16 @@ %div.branch-commit - if deployment.ref - = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" - · + .icon-container + = deployment.tag? ? icon('tag') : icon('code-fork') + = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name" + .icon-container + = custom_icon("icon_commit") = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" %p.commit-title %span - if commit_title = deployment.commit_title + = author_avatar(deployment.commit, size: 20) = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" - else Cant find HEAD commit for this branch diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index baf02f1e6a0..ca0005abd0c 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -5,13 +5,16 @@ %td = render 'projects/deployments/commit', deployment: deployment - %td + %td.build-column - if deployment.deployable - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable] do + = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do = "#{deployment.deployable.name} (##{deployment.deployable.id})" + - if deployment.user + by + = user_avatar(user: deployment.user, size: 20) %td #{time_ago_with_tooltip(deployment.created_at)} - %td + %td.hidden-xs = render 'projects/deployments/actions', deployment: deployment, allow_rollback: true diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index a1b071f130c..779c8ea0104 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -11,9 +11,11 @@ - elsif diff_file.collapsed? - url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path)) .nothing-here-block.diff-collapsed{data: { diff_for_path: url } } - This diff is collapsed. Click to expand it. + This diff is collapsed. + %a.click-to-expand + Click to expand it. - elsif diff_file.diff_lines.length > 0 - - if diff_view == 'parallel' + - if diff_view == :parallel = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob - else = render "projects/diffs/text_file", diff_file: diff_file diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index ebaf939f930..067cf595da3 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -1,7 +1,6 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) +- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_files = diffs.diff_files -- if diff_view == 'parallel' - - fluid_layout true .content-block.oneline-block.files-changed .inline-parallel-buttons @@ -22,7 +21,7 @@ - if diff_files.overflow? = render 'projects/diffs/warning', diff_files: diff_files -.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, diffs.project))}} +.files{ data: { can_create_note: can_create_note } } - diff_files.each_with_index do |diff_file, index| - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index f0a86fd6d40..257e0a855bd 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,19 +1,18 @@ -.diff-file.file-holder{id: "diff-#{index}", data: diff_file_html_data(project, diff_file)} +.diff-file.file-holder{id: "diff-#{index}", data: diff_file_html_data(project, diff_file.file_path, diff_commit.id)} .file-title{id: "file-path-#{hexdigest(diff_file.file_path)}"} = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{index}" - unless diff_file.submodule? .file-actions.hidden-xs - if blob_text_viewable?(blob) - = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip btn-file-option', title: "Toggle comments for this file" do + = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip btn-file-option', title: "Toggle comments for this file", disabled: @diff_notes_disabled do = icon('comment') \ - + = clipboard_button(clipboard_text: diff_file.new_path, class: 'btn-file-option') - if editable_diff?(diff_file) - = edit_blob_link(@merge_request.source_project, - @merge_request.source_branch, diff_file.new_path, - from_merge_request_id: @merge_request.id, - skip_visible_check: true) + - link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {} + = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, + blob: blob, link_opts: link_opts) = view_file_btn(diff_commit.id, diff_file.new_path, project) diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index 95a2772fd0b..a6a2e5690b5 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -1,3 +1,4 @@ +%i.fa.diff-toggle-caret - if defined?(blob) && blob && diff_file.submodule? %span = icon('archive fw') diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 4d3af905b58..7042e9f1fc9 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -1,11 +1,11 @@ +- email = local_assigns.fetch(:email, false) - plain = local_assigns.fetch(:plain, false) - type = line.type -- line_code = diff_file.line_code(line) unless plain +- line_code = diff_file.line_code(line) %tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } } - case type - when 'match' - = render "projects/diffs/match_line", { line: line.text, - line_old: line.old_pos, line_new: line.new_pos, bottom: false, new_file: diff_file.new_file } + = diff_match_line line.old_pos, line.new_pos, text: line.text - when 'nonewline' %td.old_line.diff-line-num %td.new_line.diff-line-num @@ -23,4 +23,15 @@ = link_text - else %a{href: "##{line_code}", data: { linenumber: link_text }} - %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type) + %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }< + - if email + %pre= diff_line_content(line.text, type) + - else + = diff_line_content(line.text, type) + +- discussions = local_assigns.fetch(:discussions, nil) +- if discussions && !line.meta? + - discussion = discussions[line_code] + - if discussion + - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?) + = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded diff --git a/app/views/projects/diffs/_match_line.html.haml b/app/views/projects/diffs/_match_line.html.haml deleted file mode 100644 index d6dddd97879..00000000000 --- a/app/views/projects/diffs/_match_line.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -%td.old_line.diff-line-num{data: {linenumber: line_old}, - class: [unfold_bottom_class(bottom), unfold_class(!new_file)]} - \... -%td.new_line.diff-line-num{data: {linenumber: line_new}, - class: [unfold_bottom_class(bottom), unfold_class(!new_file)]} - \... -%td.line_content.match= line diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 7f30faa20d8..28aad3f4725 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -1,14 +1,15 @@ / Side-by-side diff view %div.text-file.diff-wrap-lines.code.file-content.js-syntax-highlight{ data: diff_view_data } %table + - last_line = 0 - diff_file.parallel_diff_lines.each do |line| - left = line[:left] - right = line[:right] + - last_line = right.new_pos if right %tr.line_holder.parallel - if left - if left.meta? - %td.old_line.diff-line-num.empty-cell - %td.line_content.parallel.match= left.text + = diff_match_line left.old_pos, nil, text: left.text, view: :parallel - else - left_line_code = diff_file.line_code(left) - left_position = diff_file.position(left) @@ -21,8 +22,7 @@ - if right - if right.meta? - %td.old_line.diff-line-num.empty-cell - %td.line_content.parallel.match= left.text + = diff_match_line nil, right.new_pos, text: left.text, view: :parallel - else - right_line_code = diff_file.line_code(right) - right_position = diff_file.position(right) @@ -37,3 +37,5 @@ - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file) - if discussion_left || discussion_right = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right + - if !diff_file.new_file && last_line > 0 + = diff_match_line last_line, last_line, bottom: true, view: :parallel diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 5970b9abf2b..f1d2d4bf268 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -5,16 +5,12 @@ %table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } - last_line = 0 - - diff_file.highlighted_diff_lines.each do |line| - - last_line = line.new_pos - = render "projects/diffs/line", line: line, diff_file: diff_file + - discussions = @grouped_diff_discussions unless @diff_notes_disabled + = render partial: "projects/diffs/line", + collection: diff_file.highlighted_diff_lines, + as: :line, + locals: { diff_file: diff_file, discussions: discussions } - - unless @diff_notes_disabled - - line_code = diff_file.line_code(line) - - discussion = @grouped_diff_discussions[line_code] if line_code - - if discussion - = render "discussions/diff_discussion", discussion: discussion - - - if last_line > 0 - = render "projects/diffs/match_line", { line: "", - line_old: last_line, line_new: last_line, bottom: true, new_file: diff_file.new_file } + - last_line = diff_file.highlighted_diff_lines.last.new_pos + - if !diff_file.new_file && last_line > 0 + = diff_match_line last_line, last_line, bottom: true diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index b282aa52b25..c8f84b96cb7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -44,42 +44,55 @@ %hr %fieldset.features.append-bottom-0 %h5.prepend-top-0 - Features - .form-group - .checkbox - = f.label :issues_enabled do - = f.check_box :issues_enabled - %strong Issues - %br - %span.descr Lightweight issue tracking system for this project - .form-group - .checkbox - = f.label :merge_requests_enabled do - = f.check_box :merge_requests_enabled - %strong Merge Requests - %br - %span.descr Submit changes to be merged upstream - .form-group - .checkbox - = f.label :builds_enabled do - = f.check_box :builds_enabled - %strong Builds - %br - %span.descr Test and deploy your changes before merge - .form-group - .checkbox - = f.label :wiki_enabled do - = f.check_box :wiki_enabled - %strong Wiki - %br - %span.descr Pages for project documentation - .form-group - .checkbox - = f.label :snippets_enabled do - = f.check_box :snippets_enabled - %strong Snippets - %br - %span.descr Share code pastes with others out of git repository + Feature Visibility + + = f.fields_for :project_feature do |feature_fields| + .form_group.prepend-top-20 + .row + .col-md-9 + = feature_fields.label :issues_access_level, "Issues", class: 'label-light' + %span.help-block Lightweight issue tracking system for this project + .col-md-3 + = project_feature_access_select(:issues_access_level) + + .row + .col-md-9 + = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light' + %span.help-block Submit changes to be merged upstream + .col-md-3 + = project_feature_access_select(:merge_requests_access_level) + + .row + .col-md-9 + = feature_fields.label :builds_access_level, "Builds", class: 'label-light' + %span.help-block Submit Test and deploy your changes before merge + .col-md-3 + = project_feature_access_select(:builds_access_level) + + .row + .col-md-9 + = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light' + %span.help-block Pages for project documentation + .col-md-3 + = project_feature_access_select(:wiki_access_level) + + .row + .col-md-9 + = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light' + %span.help-block Share code pastes with others out of Git repository + .col-md-3 + = project_feature_access_select(:snippets_access_level) + + - if Gitlab.config.lfs.enabled && current_user.admin? + .row + .col-md-9 + = f.label :lfs_enabled, 'LFS', class: 'label-light' + %span.help-block + Git Large File Storage + = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') + .col-md-3 + = f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control', data: { field: 'lfs_enabled' } + - if Gitlab.config.registry.enabled .form-group .checkbox @@ -87,8 +100,9 @@ = f.check_box :container_registry_enabled %strong Container Registry %br - %span.descr Enable Container Registry for this repository - %hr + %span.descr Enable Container Registry for this project + = link_to icon('question-circle'), help_page_path('user/project/container_registry'), target: '_blank' + = render 'merge_request_settings', f: f %hr %fieldset.features.append-bottom-default diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 636beb73ec2..7a39064adc5 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -23,6 +23,8 @@ or a = link_to '.gitignore', add_special_file_path(@project, file_name: '.gitignore'), class: 'underlined-link' to this project. + %p + You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected. - if can?(current_user, :push_code, @project) %div{ class: container_class } diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index e2453395602..251694e897c 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -2,8 +2,19 @@ %tr.environment %td - %strong - = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) + + %td.deployment-column + - if last_deployment + %span ##{last_deployment.iid} + - if last_deployment.user + by + = user_avatar(user: last_deployment.user, size: 20) + + %td + - if last_deployment && last_deployment.deployable + = link_to [@project.namespace.becomes(Namespace), @project, last_deployment.deployable], class: 'build-link' do + = "#{last_deployment.deployable.name} (##{last_deployment.deployable.id})" %td - if last_deployment @@ -16,5 +27,5 @@ - if last_deployment #{time_ago_with_tooltip(last_deployment.created_at)} - %td + %td.hidden-xs = render 'projects/deployments/actions', deployment: last_deployment diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index a6dd34653ab..ab801409722 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -9,24 +9,27 @@ = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do New environment - - if @environments.blank? - .blank-state.blank-state-no-icon - %h2.blank-state-title - You don't have any environments right now. - %p.blank-state-text - Environments are places where code gets deployed, such as staging or production. - %br - = succeed "." do - = link_to "Read more about environments", help_page_path("ci/environments") - - if can?(current_user, :create_environment, @project) - = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do - New environment - - else - .table-holder - %table.table.environments - %tbody - %th Environment - %th Last deployment - %th Date - %th - = render @environments + .environments-container + - if @environments.blank? + .blank-state.blank-state-no-icon + %h2.blank-state-title + You don't have any environments right now. + %p.blank-state-text + Environments are places where code gets deployed, such as staging or production. + %br + = succeed "." do + = link_to "Read more about environments", help_page_path("ci/environments") + - if can?(current_user, :create_environment, @project) + = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do + New environment + - else + .table-holder + %table.table.builds.environments + %tbody + %th Environment + %th Last Deployment + %th Build + %th Commit + %th + %th.hidden-xs + = render @environments diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index a07436ad7c9..7a8d196cf4e 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -12,26 +12,27 @@ = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to delete this environment?' }, class: 'btn btn-danger', method: :delete - - if @deployments.blank? - .blank-state.blank-state-no-icon - %h2.blank-state-title - You don't have any deployments right now. - %p.blank-state-text - Define environments in the deploy stage(s) in - %code .gitlab-ci.yml - to track deployments here. - = link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success" - - else - .table-holder - %table.table.environments - %thead - %tr - %th ID - %th Commit - %th Build - %th Date - %th + .deployments-container + - if @deployments.blank? + .blank-state.blank-state-no-icon + %h2.blank-state-title + You don't have any deployments right now. + %p.blank-state-text + Define environments in the deploy stage(s) in + %code .gitlab-ci.yml + to track deployments here. + = link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success" + - else + .table-holder + %table.table.builds.environments + %thead + %tr + %th ID + %th Commit + %th Build + %th + %th.hidden-xs - = render @deployments + = render @deployments - = paginate @deployments, theme: 'gitlab' + = paginate @deployments, theme: 'gitlab' diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index a1d79bdabda..abf4f697f86 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -15,7 +15,7 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - %b.caret + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right %li - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id] @@ -32,11 +32,11 @@ - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn btn-new' do = custom_icon('icon_fork') - Fork + %span Fork - else = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork project", class: 'btn btn-new' do = custom_icon('icon_fork') - Fork + %span Fork = render 'projects', projects: @forks diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 331dc1fcc29..80fe6be49b0 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -62,5 +62,3 @@ %td.coverage - if generic_commit_status.try(:coverage) #{generic_commit_status.coverage}% - - %td diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml new file mode 100644 index 00000000000..0a66d60accc --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml @@ -0,0 +1,9 @@ +- if subject.target_url + = link_to subject.target_url do + %span.ci-status-icon + = render_status_with_link('commit status', subject.status) + %span.ci-status-text= subject.name +- else + %span.ci-status-icon + = render_status_with_link('commit status', subject.status) + %span.ci-status-text= subject.name diff --git a/app/views/projects/graphs/_head.html.haml b/app/views/projects/graphs/_head.html.haml index 45e51389c00..1a62a6a809c 100644 --- a/app/views/projects/graphs/_head.html.haml +++ b/app/views/projects/graphs/_head.html.haml @@ -1,16 +1,19 @@ -.nav-links.sub-nav - %ul{ class: (container_class) } += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } - - content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/chart.js') - = page_specific_javascript_tag('graphs/graphs_bundle.js') - = nav_link(action: :show) do - = link_to 'Contributors', namespace_project_graph_path - = nav_link(action: :commits) do - = link_to 'Commits', commits_namespace_project_graph_path - = nav_link(action: :languages) do - = link_to 'Languages', languages_namespace_project_graph_path - - if @project.builds_enabled? - = nav_link(action: :ci) do - = link_to ci_namespace_project_graph_path do - Continuous Integration + - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/chart.js') + = page_specific_javascript_tag('graphs/graphs_bundle.js') + = nav_link(action: :show) do + = link_to 'Contributors', namespace_project_graph_path + = nav_link(action: :commits) do + = link_to 'Commits', commits_namespace_project_graph_path + = nav_link(action: :languages) do + = link_to 'Languages', languages_namespace_project_graph_path + - if @project.feature_available?(:builds, current_user) + = nav_link(action: :ci) do + = link_to ci_namespace_project_graph_path do + Continuous Integration diff --git a/app/views/projects/group_links/index.html.haml b/app/views/projects/group_links/index.html.haml index 2b904544f28..1b0dbbb8111 100644 --- a/app/views/projects/group_links/index.html.haml +++ b/app/views/projects/group_links/index.html.haml @@ -8,15 +8,22 @@ .col-lg-9 %h5.prepend-top-0 Set a group to share - = form_tag namespace_project_group_links_path(@project.namespace, @project), method: :post do + = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do .form-group = label_tag :link_group_id, "Group", class: "label-light" - = groups_select_tag(:link_group_id, skip_group: @project.group.try(:path)) + = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true) .form-group = label_tag :link_group_access, "Max access level", class: "label-light" .select-wrapper = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" - %span.caret + = icon('caret-down') + .form-group + = label_tag :expires_at, 'Access expiration date', class: 'label-light' + .clearable-input + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date' + %i.clear-icon.js-clear-input + .help-block + On this date, all users in the group will automatically lose access to this project. = submit_tag "Share", class: "btn btn-create" .col-lg-9.col-lg-offset-3 %hr @@ -35,6 +42,10 @@ = group.name %br up to #{group_link.human_access} + - if group_link.expires? + · + %span{ class: ('text-warning' if group_link.expires_soon?) } + expires in #{distance_of_time_in_words_to_now(group_link.expires_at)} .pull-right = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: "btn btn-transparent" do %span.sr-only disable sharing diff --git a/app/views/projects/hooks/_project_hook.html.haml b/app/views/projects/hooks/_project_hook.html.haml index 8151187d499..ceabe2eab3d 100644 --- a/app/views/projects/hooks/_project_hook.html.haml +++ b/app/views/projects/hooks/_project_hook.html.haml @@ -3,7 +3,7 @@ .col-md-8.col-lg-7 %strong.light-header= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray.deploy-project-label= trigger.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 60b45115b73..4825820c4d9 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -1,25 +1,33 @@ -.nav-links.sub-nav - %ul{ class: (container_class) } - - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) - = nav_link(controller: :issues) do - = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do - %span - Issues += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) + = nav_link(controller: :issues) do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do + %span + Issues - - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests) - = nav_link(controller: :merge_requests) do - = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do - %span - Merge Requests + = nav_link(controller: :boards) do + = link_to namespace_project_boards_path(@project.namespace, @project), title: 'Board' do + %span + Board - - if project_nav_tab? :labels - = nav_link(controller: :labels) do - = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do - %span - Labels + - if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests) + = nav_link(controller: :merge_requests) do + = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do + %span + Merge Requests - - if project_nav_tab? :milestones - = nav_link(controller: :milestones) do - = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do - %span - Milestones + - if project_nav_tab? :labels + = nav_link(controller: :labels) do + = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do + %span + Labels + + - if project_nav_tab? :milestones + = nav_link(controller: :milestones) do + = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do + %span + Milestones diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 79b14819865..8b1a8a8a2d9 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,7 +1,7 @@ %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } - - if controller.controller_name == 'issues' && can?(current_user, :admin_issue, @project) + - if @bulk_edit .issue-check - = check_box_tag dom_id(issue,"selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" + = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" .issue-title.title %span.issue-title-text @@ -29,7 +29,7 @@ - note_count = issue.notes.user.count %li - = link_to issue_path(issue, anchor: 'notes'), class: ('issue-no-comments' if note_count.zero?) do + = link_to issue_path(issue, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do = icon('comments') = note_count diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index f34f3c05737..a2c31c0b4c5 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,4 +1,4 @@ -%ul.content-list.issues-list +%ul.content-list.issues-list.issuable-list = render @issues - if @issues.blank? %li diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index d8075371853..31d3ec23276 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -1,7 +1,7 @@ - if @merge_requests.any? %h2.merge-requests-title = pluralize(@merge_requests.count, 'Related Merge Request') - %ul.unstyled-list + %ul.unstyled-list.related-merge-requests - has_any_ci = @merge_requests.any?(&:pipeline) - @merge_requests.each do |merge_request| %li diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 24749699c6d..c56b6cc11f5 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,13 +1,12 @@ - if can?(current_user, :push_code, @project) .pull-right - #new-branch{'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue)} + #new-branch.new-branch{'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue)} + = link_to '#', class: 'checking btn btn-grouped', disabled: 'disabled' do + = icon('spinner spin') + Checking branches = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), - method: :post, class: 'btn btn-new btn-inverted has-tooltip', title: @issue.to_branch_name, disabled: 'disabled' do - .checking - = icon('spinner spin') - Checking branches - .available.hide - New branch - .unavailable.hide - = icon('exclamation-triangle') - New branch unavailable + method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do + New branch + = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do + = icon('exclamation-triangle') + New branch unavailable diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 6ea9f612d13..44683c8bcdb 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,11 +1,11 @@ - if @related_branches.any? %h2.related-branches-title = pluralize(@related_branches.count, 'Related Branch') - %ul.unstyled-list + %ul.unstyled-list.related-merge-requests - @related_branches.each do |branch| %li - target = @project.repository.find_branch(branch).target - - pipeline = @project.pipeline(target.sha, branch) if target + - pipeline = @project.pipeline_for(branch, target.sha) if target - if pipeline %span.related-branch-ci-status = render_pipeline_status(pipeline) diff --git a/app/views/projects/issues/edit.html.haml b/app/views/projects/issues/edit.html.haml index 7cf1923456e..3a6fbbc7fbc 100644 --- a/app/views/projects/issues/edit.html.haml +++ b/app/views/projects/issues/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", "#{@issue.title} (##{@issue.iid})", "Issues" +- page_title "Edit", "#{@issue.to_reference} #{@issue.title}", "Issues" %h3.page-title Edit Issue ##{@issue.iid} diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 1a87045aa60..cc57cfdb342 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,7 +1,10 @@ - @no_container = true +- @bulk_edit = can?(current_user, :admin_issue, @project) + - page_title "Issues" - new_issue_email = @project.new_issue_address(current_user) -= render "projects/issues/head" += content_for :sub_nav do + = render "projects/issues/head" = content_for :meta_tags do - if current_user diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index e5cce16a171..09347ad5fff 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -1,4 +1,4 @@ -- page_title "#{@issue.title} (##{@issue.iid})", "Issues" +- page_title "#{@issue.to_reference} #{@issue.title}", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes @@ -22,9 +22,9 @@ - if can?(current_user, :create_issue, @project) || can?(current_user, :update_issue, @issue) .issuable-actions .clearfix.issue-btn-group.dropdown - %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ data: { toggle: "dropdown" } } - %span.caret + %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } Options + = icon('caret-down') .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul - if can?(current_user, :create_issue, @project) @@ -37,25 +37,30 @@ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + - if @issue.submittable_as_spam? && current_user.admin? + %li + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + - if can?(current_user, :create_issue, @project) = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - = link_to edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' do - Edit + - if @issue.submittable_as_spam? && current_user.admin? + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details .detail-page-description.content-block %h2.title - = markdown escape_once(@issue.title), pipeline: :single_line, author: @issue.author + = markdown_field(@issue, :title) - if @issue.description.present? .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } .wiki = preserve do - = markdown(@issue.description, cache_key: [@issue, "description"], author: @issue.author) + = markdown_field(@issue, :description) %textarea.hidden.js-task-list-field = @issue.description = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') diff --git a/app/views/projects/labels/_form.html.haml b/app/views/projects/labels/_form.html.haml index aa143e54ffe..6ab6ae50389 100644 --- a/app/views/projects/labels/_form.html.haml +++ b/app/views/projects/labels/_form.html.haml @@ -14,7 +14,7 @@ .col-sm-10 .input-group .input-group-addon.label-color-preview - = f.color_field :color, class: "form-control" + = f.text_field :color, class: "form-control" .help-block Choose any color. %br diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index 73c6f2a046c..71f7f354d72 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -5,7 +5,7 @@ .visible-xs.visible-sm-inline-block.visible-md-inline-block.dropdown %button.btn.btn-default.label-options-toggle{ data: { toggle: "dropdown" } } Options - %span.caret + = icon('caret-down') .dropdown-menu.dropdown-menu-align-right %ul %li diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 53dd300c35c..cfb44bd206c 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -2,7 +2,10 @@ - if can?(current_user, :update_merge_request, @merge_request) - if @merge_request.open? = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} - - if @merge_request.closed? + - if @merge_request.reopenable? = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} + %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" } + %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } + {{ buttonText }} #notes= render "projects/notes/notes_with_form" diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 5029b365f93..68fb7d5a414 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,4 +1,8 @@ %li{ class: mr_css_classes(merge_request) } + - if @bulk_edit + .issue-check + = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue" + .merge-request-title.title %span.merge-request-title-text = link_to merge_request.title, merge_request_path(merge_request) @@ -37,7 +41,7 @@ - note_count = merge_request.mr_and_commit_notes.user.count %li - = link_to merge_request_path(merge_request, anchor: 'notes'), class: ('merge-request-no-comments' if note_count.zero?) do + = link_to merge_request_path(merge_request, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do = icon('comments') = note_count diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index 446887774a4..fe82f751f53 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -1,4 +1,4 @@ -%ul.content-list.mr-list +%ul.content-list.mr-list.issuable-list = render @merge_requests - if @merge_requests.blank? %li diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index de39964fca8..466ec1475d8 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -65,19 +65,6 @@ - if @merge_request.errors.any? = form_errors(@merge_request) - - elsif @merge_request.source_branch.present? && @merge_request.target_branch.present? - .light-well.append-bottom-default - .center - %h4 - There isn't anything to merge. - %p.slead - - if @merge_request.source_branch == @merge_request.target_branch - You'll need to use different branch names to get a valid comparison. - - else - %span.label-branch #{@merge_request.source_branch} - and - %span.label-branch #{@merge_request.target_branch} - are the same. = f.submit 'Compare branches and continue', class: "btn btn-new mr-compare-btn" :javascript diff --git a/app/views/projects/merge_requests/_new_diffs.html.haml b/app/views/projects/merge_requests/_new_diffs.html.haml new file mode 100644 index 00000000000..74367ab9b7b --- /dev/null +++ b/app/views/projects/merge_requests/_new_diffs.html.haml @@ -0,0 +1 @@ += render "projects/diffs/diffs", diffs: @diffs, show_whitespace_toggle: false diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index 598bd743676..da6927879a4 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -18,45 +18,46 @@ = f.hidden_field :target_branch .mr-compare.merge-request - %ul.merge-request-tabs.nav-links.no-top.no-bottom - %li.commits-tab - = link_to url_for(params), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do - Commits - %span.badge= @commits.size - - if @pipeline - %li.builds-tab.active - = link_to url_for(params), data: {target: 'div#builds', action: 'builds', toggle: 'tab'} do - Builds - %span.badge= @statuses.size - %li.diffs-tab.active - = link_to url_for(params), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do - Changes - %span.badge= @diffs.real_size + - if @commits.empty? + .commits-empty + %h4 + There are no commits yet. + = custom_icon ('illustration_no_commits') + - else + %ul.merge-request-tabs.nav-links.no-top.no-bottom + %li.commits-tab.active + = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do + Commits + %span.badge= @commits.size + - if @pipeline + %li.builds-tab + = link_to url_for(params), data: {target: 'div#builds', action: 'builds', toggle: 'tab'} do + Builds + %span.badge= @statuses.size + %li.diffs-tab + = link_to url_for(params.merge(action: 'new_diffs')), data: {target: 'div#diffs', action: 'new/diffs', toggle: 'tab'} do + Changes + %span.badge= @merge_request.diff_size - .tab-content - #commits.commits.tab-pane - = render "projects/merge_requests/show/commits" - #diffs.diffs.tab-pane.active - - if @commits.size > MergeRequestDiff::COMMITS_SAFE_SIZE - .alert.alert-danger - %h4 This comparison includes more than #{MergeRequestDiff::COMMITS_SAFE_SIZE} commits. - %p To preserve performance the line changes are not shown. - - else - = render "projects/diffs/diffs", diffs: @diffs, show_whitespace_toggle: false - - if @pipeline - #builds.builds.tab-pane - = render "projects/merge_requests/show/builds" + .tab-content + #commits.commits.tab-pane.active + = render "projects/merge_requests/show/commits" + #diffs.diffs.tab-pane + - # This tab is always loaded via AJAX + - if @pipeline + #builds.builds.tab-pane + = render "projects/merge_requests/show/builds" + + .mr-loading-status + = spinner :javascript $('.assign-to-me-link').on('click', function(e){ $('#merge_request_assignee_id').val("#{current_user.id}").trigger("change"); e.preventDefault(); }); - :javascript - var merge_request - merge_request = new MergeRequest({ - action: 'new', - diffs_loaded: true, - commits_loaded: true + var merge_request = new MergeRequest({ + action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}", + buildsLoaded: "#{@pipeline ? 'true' : 'false'}" }); diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 873ed9b59ee..47dd51639b5 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,9 +1,8 @@ -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" +- page_title "#{@merge_request.to_reference} #{@merge_request.title}", "Merge Requests" - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - -- if diff_view == 'parallel' - - fluid_layout true +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('diff_notes/diff_notes_bundle.js') .merge-request{'data-url' => merge_request_path(@merge_request)} = render "projects/merge_requests/show/mr_title" @@ -14,27 +13,32 @@ - if @merge_request.open? .pull-right - if @merge_request.source_branch_exists? + - if koding_enabled? && @repository.koding_yml + = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank' do + Run in IDE (Koding) = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do Check out branch %span.dropdown.inline.prepend-left-5 %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } Download as - %span.caret + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) - .normal - %span Request to merge - %span.label-branch= source_branch_with_namespace(@merge_request) - %span into - %span.label-branch - = link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch) - - if @merge_request.open? && @merge_request.diverged_from_target_branch? - %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind) + - unless @merge_request.closed_without_fork? + .normal + %span Request to merge + %span.label-branch= source_branch_with_namespace(@merge_request) + %span into + %span.label-branch + = link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch) + - if @merge_request.open? && @merge_request.diverged_from_target_branch? + %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind) - = render "projects/merge_requests/show/how_to_merge" - = render "projects/merge_requests/widget/show.html.haml" + - unless @merge_request.closed_without_source_project? + = render "projects/merge_requests/show/how_to_merge" + = render "projects/merge_requests/widget/show.html.haml" - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) .light.prepend-top-default.append-bottom-default @@ -45,26 +49,41 @@ - if @commits_count.nonzero? %ul.merge-request-tabs.nav-links.no-top.no-bottom %li.notes-tab - = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do + = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do Discussion %span.badge= @merge_request.mr_and_commit_notes.user.count - %li.commits-tab - = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do - Commits - %span.badge= @commits_count + - unless @merge_request.closed_without_source_project? + %li.commits-tab + = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do + Commits + %span.badge= @commits_count - if @pipeline + %li.pipelines-tab + = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do + Pipelines + %span.badge= @merge_request.all_pipelines.size %li.builds-tab - = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do + = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#builds', action: 'builds', toggle: 'tab' } do Builds %span.badge= @statuses.size %li.diffs-tab - = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do + = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do Changes %span.badge= @merge_request.diff_size + %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + .line-resolve-all{ "v-show" => "discussionCount > 0", + ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } + %span.line-resolve-btn.is-disabled{ type: "button", + ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } + = render "shared/icons/icon_status_success.svg" + %span.line-resolve-text + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ discussionCount | pluralize 'discussion' }} resolved + = render "discussions/jump_to_next" - .tab-content + .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes - .content-block.content-block-small.oneline-block + .content-block.content-block-small = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .row @@ -76,6 +95,8 @@ - # This tab is always loaded via AJAX #builds.builds.tab-pane - # This tab is always loaded via AJAX + #pipelines.pipelines.tab-pane + - # This tab is always loaded via AJAX #diffs.diffs.tab-pane - # This tab is always loaded via AJAX diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml new file mode 100644 index 00000000000..a524936f73c --- /dev/null +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -0,0 +1,29 @@ +- class_bindings = "{ | + 'head': line.isHead, | + 'origin': line.isOrigin, | + 'match': line.hasMatch, | + 'selected': line.isSelected, | + 'unselected': line.isUnselected }" + +- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" += render "projects/merge_requests/show/mr_title" + +.merge-request-details.issuable-details + = render "projects/merge_requests/show/mr_box" + += render 'shared/issuable/sidebar', issuable: @merge_request + +#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json), + resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } } + .loading{"v-if" => "isLoading"} + %i.fa.fa-spinner.fa-spin + + .nothing-here-block{"v-if" => "hasError"} + {{conflictsData.errorMessage}} + + = render partial: "projects/merge_requests/conflicts/commit_stats" + + .files-wrapper{"v-if" => "!isLoading && !hasError"} + = render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings } + = render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings } + = render partial: "projects/merge_requests/conflicts/submit_form" diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml new file mode 100644 index 00000000000..457c467fba9 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml @@ -0,0 +1,20 @@ +.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"} + .inline-parallel-buttons + .btn-group + %a.btn{ | + ":class" => "{'active': !isParallel}", | + "@click" => "handleViewTypeChange('inline')"} + Inline + %a.btn{ | + ":class" => "{'active': isParallel}", | + "@click" => "handleViewTypeChange('parallel')"} + Side-by-side + + .js-toggle-container + .commit-stat-summary + Showing + %strong.cred {{conflictsCount}} {{conflictsData.conflictsText}} + between + %strong {{conflictsData.source_branch}} + and + %strong {{conflictsData.target_branch}} diff --git a/app/views/projects/merge_requests/conflicts/_inline_view.html.haml b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml new file mode 100644 index 00000000000..19c7da4b5e3 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml @@ -0,0 +1,28 @@ +.files{"v-show" => "!isParallel"} + .diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"} + .file-title + %i.fa.fa-fw{":class" => "file.iconClass"} + %strong {{file.filePath}} + .file-actions + %a.btn.view-file.btn-file-option{":href" => "file.blobPath"} + View file @{{conflictsData.shortCommitSha}} + + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight + %table + %tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"} + %template{"v-if" => "!line.isHeader"} + %td.diff-line-num.new_line{":class" => class_bindings} + %a {{line.new_line}} + %td.diff-line-num.old_line{":class" => class_bindings} + %a {{line.old_line}} + %td.line_content{":class" => class_bindings} + {{{line.richText}}} + + %template{"v-if" => "line.isHeader"} + %td.diff-line-num.header{":class" => class_bindings} + %td.diff-line-num.header{":class" => class_bindings} + %td.line_content.header{":class" => class_bindings} + %strong {{{line.richText}}} + %button.btn{"@click" => "handleSelected(line.id, line.section)"} + {{line.buttonTitle}} diff --git a/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml new file mode 100644 index 00000000000..2e6f67c2eaf --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml @@ -0,0 +1,27 @@ +.files{"v-show" => "isParallel"} + .diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"} + .file-title + %i.fa.fa-fw{":class" => "file.iconClass"} + %strong {{file.filePath}} + .file-actions + %a.btn.view-file.btn-file-option{":href" => "file.blobPath"} + View file @{{conflictsData.shortCommitSha}} + + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight + %table + %tr.line_holder.parallel{"v-for" => "section in file.parallelLines"} + %template{"v-for" => "line in section"} + + %template{"v-if" => "line.isHeader"} + %td.diff-line-num.header{":class" => class_bindings} + %td.line_content.header{":class" => class_bindings} + %strong {{line.richText}} + %button.btn{"@click" => "handleSelected(line.id, line.section)"} + {{line.buttonTitle}} + + %template{"v-if" => "!line.isHeader"} + %td.diff-line-num.old_line{":class" => class_bindings} + {{line.lineNumber}} + %td.line_content.parallel{":class" => class_bindings} + {{{line.richText}}} diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml new file mode 100644 index 00000000000..78bd4133ea2 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -0,0 +1,15 @@ +.content-block.oneline-block.files-changed + %strong.resolved-count {{resolvedCount}} + of + %strong.total-count {{conflictsCount}} + conflicts have been resolved + + .commit-message-container.form-group + .max-width-marker + %textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"} + {{{conflictsData.commitMessage}}} + + %button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"} + %span {{commitButtonText}} + + = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel" diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml index 03159f123f3..7c3ac6652ee 100644 --- a/app/views/projects/merge_requests/edit.html.haml +++ b/app/views/projects/merge_requests/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" +- page_title "Edit", "#{@merge_request.to_reference} #{@merge_request.title}", "Merge Requests" %h3.page-title Edit Merge Request #{@merge_request.to_reference} diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index ace275c689b..144b3a9c8c8 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -1,4 +1,6 @@ - @no_container = true +- @bulk_edit = can?(current_user, :admin_merge_request, @project) + - page_title "Merge Requests" = render "projects/issues/head" = render 'projects/last_push' diff --git a/app/views/projects/merge_requests/show/_builds.html.haml b/app/views/projects/merge_requests/show/_builds.html.haml index 81de60f116c..808ef7fed27 100644 --- a/app/views/projects/merge_requests/show/_builds.html.haml +++ b/app/views/projects/merge_requests/show/_builds.html.haml @@ -1,2 +1 @@ = render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true - diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml index 013b05628fa..99c71e1454a 100644 --- a/app/views/projects/merge_requests/show/_diffs.html.haml +++ b/app/views/projects/merge_requests/show/_diffs.html.haml @@ -1,4 +1,5 @@ - if @merge_request_diff.collected? + = render 'projects/merge_requests/show/versions' = render "projects/diffs/diffs", diffs: @diffs - elsif @merge_request_diff.empty? .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch} 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 b727efaa6a6..f1d5441f9dd 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 @@ -12,7 +12,7 @@ %pre.dark#merge-info-1 - if @merge_request.for_fork? :preserve - git fetch #{h @merge_request.source_project.http_url_to_repo} #{h @merge_request.source_branch} + git fetch #{h default_url_to_repo(@merge_request.source_project)} #{h @merge_request.source_branch} git checkout -b #{h @merge_request.source_project_path}-#{h @merge_request.source_branch} FETCH_HEAD - else :preserve @@ -47,8 +47,9 @@ Note that pushing to GitLab requires write access to this repository. %p %strong Tip: - You can also checkout merge requests locally by - %a{href: 'https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/workflow/merge_requests.md#checkout-merge-requests-locally', target: '_blank'} following these guidelines + = succeed '.' do + You can also checkout merge requests locally by + = link_to 'following these guidelines', help_page_path('user/project/merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank' :javascript $(function(){ diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml index ebf18f6ac85..ed23d06ee5e 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/show/_mr_box.html.haml @@ -1,13 +1,13 @@ .detail-page-description.content-block %h2.title - = markdown escape_once(@merge_request.title), pipeline: :single_line, author: @merge_request.author + = markdown_field(@merge_request, :title) %div - if @merge_request.description.present? .description{class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : ''} .wiki = preserve do - = markdown(@merge_request.description, cache_key: [@merge_request, "description"], author: @merge_request.author) + = markdown_field(@merge_request, :description) %textarea.hidden.js-task-list-field = @merge_request.description diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml index b24bdf22ceb..e7c5bca6a37 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -1,3 +1,7 @@ +- if @merge_request.closed_without_fork? + .alert.alert-danger + %p The source project of this merge request has been removed. + .clearfix.detail-page-header .issuable-header .issuable-status-box.status-box{ class: status_box_class(@merge_request) } @@ -14,9 +18,9 @@ - if can?(current_user, :update_merge_request, @merge_request) .issuable-actions .clearfix.issue-btn-group.dropdown - %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ data: { toggle: "dropdown" } } - %span.caret + %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } Options + = icon('caret-down') .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul %li{ class: merge_request_button_visibility(@merge_request, true) } diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml new file mode 100644 index 00000000000..afe3f3430c6 --- /dev/null +++ b/app/views/projects/merge_requests/show/_pipelines.html.haml @@ -0,0 +1 @@ += render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml new file mode 100644 index 00000000000..eab48b78cb3 --- /dev/null +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -0,0 +1,84 @@ +- if @merge_request_diffs.size > 1 + .mr-version-controls + %div.mr-version-menus-container.content-block + Changes between + %span.dropdown.inline.mr-version-dropdown + %a.dropdown-toggle.btn.btn-default{ data: {toggle: :dropdown} } + %span + - if @merge_request_diff.latest? + latest version + - else + version #{version_index(@merge_request_diff)} + = icon('caret-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Version: + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times', class: 'dropdown-menu-close-icon') + .dropdown-content + %ul + - @merge_request_diffs.each do |merge_request_diff| + %li + = link_to merge_request_version_path(@project, @merge_request, merge_request_diff), class: ('is-active' if merge_request_diff == @merge_request_diff) do + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + .monospace #{short_sha(merge_request_diff.head_commit_sha)} + %small + #{number_with_delimiter(merge_request_diff.commits.count)} #{'commit'.pluralize(merge_request_diff.commits.count)}, + = time_ago_with_tooltip(merge_request_diff.created_at) + + - if @merge_request_diff.base_commit_sha + and + %span.dropdown.inline.mr-version-compare-dropdown + %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } + %span + - if @start_sha + version #{version_index(@start_version)} + - else + #{@merge_request.target_branch} + = icon('caret-down') + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Compared with: + %button.dropdown-title-button.dropdown-menu-close{aria: {label: "Close"}} + = icon('times', class: 'dropdown-menu-close-icon') + .dropdown-content + %ul + - @comparable_diffs.each do |merge_request_diff| + %li + = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + .monospace #{short_sha(merge_request_diff.head_commit_sha)} + %small + = time_ago_with_tooltip(merge_request_diff.created_at) + %li + = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do + %strong + #{@merge_request.target_branch} (base) + .monospace #{short_sha(@merge_request_diff.base_commit_sha)} + + - if different_base?(@start_version, @merge_request_diff) + .content-block + = icon('info-circle') + Selected versions have different base commits. + Changes will include + = link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do + new commits + from + %code #{@merge_request.target_branch} + + - unless @merge_request_diff.latest? && !@start_sha + .comments-disabled-notif.content-block + = icon('info-circle') + - if @start_sha + Comments are disabled because you're comparing two versions of this merge request. + - else + Comments are disabled because you're viewing an old version of this merge request. + = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 6ef640bb654..a82c846baa7 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -4,14 +4,15 @@ .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } = ci_icon_for_status(status) %span - CI build + Pipeline + = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline' = ci_label_for_status(status) for - commit = @merge_request.diff_head_commit = succeed "." do = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace" %span.ci-coverage - = link_to "View details", builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'builds'} + = link_to "View details", pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'pipelines'} - elsif @merge_request.has_ci? - # Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX @@ -42,3 +43,6 @@ .ci_widget.ci-error{style: "display:none"} = icon("times-circle") Could not connect to the CI server. Please check your settings and try again. + +.js-success-icon.hidden + = ci_icon_for_status('success') diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml index 19b5d0ff066..7794d6d7df2 100644 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ b/app/views/projects/merge_requests/widget/_merged.html.haml @@ -6,7 +6,7 @@ - if @merge_request.merge_event by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + - if !@merge_request.source_branch_exists? || params[:deleted_source_branch] %p The changes were merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index dc18f715f25..842b6df310d 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -1,6 +1,12 @@ .mr-state-widget = render 'projects/merge_requests/widget/heading' .mr-widget-body + -# After conflicts are resolved, the user is redirected back to the MR page. + -# There is a short window before background workers run and GitLab processes + -# the new push and commits, during which it will think the conflicts still exist. + -# We send this param to get the widget to treat the MR as having no more conflicts. + - resolved_conflicts = params[:resolved_conflicts] + - if @project.archived? = render 'projects/merge_requests/widget/open/archived' - elsif @merge_request.commits.blank? @@ -9,7 +15,7 @@ = render 'projects/merge_requests/widget/open/missing_branch' - elsif @merge_request.unchecked? = render 'projects/merge_requests/widget/open/check' - - elsif @merge_request.cannot_be_merged? + - elsif @merge_request.cannot_be_merged? && !resolved_conflicts = render 'projects/merge_requests/widget/open/conflicts' - elsif @merge_request.work_in_progress? = render 'projects/merge_requests/widget/open/wip' @@ -19,7 +25,7 @@ = render 'projects/merge_requests/widget/open/not_allowed' - elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed? = render 'projects/merge_requests/widget/open/build_failed' - - elsif @merge_request.can_be_merged? + - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' - if mr_closes_issues.present? @@ -29,3 +35,4 @@ Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} = succeed '.' do != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author + = mr_assign_issues_link diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml index d9efe81701f..608fdf1c5f5 100644 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ b/app/views/projects/merge_requests/widget/_show.html.haml @@ -12,6 +12,7 @@ merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", check_enable: #{@merge_request.unchecked? ? "true" : "false"}, ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", + ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", gitlab_icon: "#{asset_path 'gitlab_logo.png'}", ci_status: "#{@merge_request.pipeline ? @merge_request.pipeline.status : ''}", ci_message: { @@ -23,7 +24,8 @@ preparing: "{{status}} build", normal: "Build {{status}}" }, - builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" + builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", + pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" }; if (typeof merge_request_widget !== 'undefined') { @@ -32,4 +34,4 @@ merge_request_widget.clearEventListeners(); } - merge_request_widget = new MergeRequestWidget(opts); + merge_request_widget = new window.gl.MergeRequestWidget(opts); diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml index 9455efc5c69..2e3e1614cb3 100644 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml @@ -12,7 +12,7 @@ Merge When Build Succeeds - unless @project.only_allow_merge_if_build_succeeds? = button_tag class: "btn btn-success dropdown-toggle", 'data-toggle' => 'dropdown' do - %span.caret + = icon('caret-down') %span.sr-only Select Merge Moment %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' } diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml index f000cc38a65..af3096f04d9 100644 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml @@ -3,7 +3,18 @@ This merge request contains merge conflicts %p - Please resolve these conflicts or + Please + - if @merge_request.conflicts_can_be_resolved_by?(current_user) + - if @merge_request.conflicts_can_be_resolved_in_ui? + = link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + - else + %span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"} + resolve these conflicts locally + - else + resolve these conflicts + + or + - if @merge_request.can_be_merged_via_command_line_by?(current_user) #{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}. - else diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 73772cc0e32..e62f810a521 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -30,13 +30,13 @@ .detail-page-description.milestone-detail %h2.title - = markdown escape_once(@milestone.title), pipeline: :single_line + = markdown_field(@milestone, :title) %div - if @milestone.description.present? .description .wiki = preserve do - = markdown @milestone.description + = markdown_field(@milestone, :description) - if @milestone.total_items_count(current_user).zero? .alert.alert-success.prepend-top-default diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index b2ece44d966..29df1bab04e 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -8,7 +8,7 @@ .project-network .controls = form_tag namespace_project_network_path(@project.namespace, @project, @id), method: :get, class: 'form-inline network-form' do |f| - = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: "Input an extended SHA1 syntax", class: 'search-input form-control input-mx-250 search-sha' + = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: "Git revision", class: 'search-input form-control input-mx-250 search-sha' = button_tag class: 'btn btn-success' do = icon('search') .inline.prepend-left-20 diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index facdfcc9447..cc8cb134fb8 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -46,54 +46,35 @@ %div - if github_import_enabled? = link_to new_import_github_path, class: 'btn import_github' do - = icon 'github', text: 'GitHub' + = icon('github', text: 'GitHub') %div - if bitbucket_import_enabled? - - if bitbucket_import_configured? - = link_to status_import_bitbucket_path, class: 'btn import_bitbucket', "data-no-turbolink" => "true" do - %i.fa.fa-bitbucket - Bitbucket - - else - = link_to status_import_bitbucket_path, class: 'how_to_import_link btn import_bitbucket', "data-no-turbolink" => "true" do - %i.fa.fa-bitbucket - Bitbucket + = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}", "data-no-turbolink" => "true" do + = icon('bitbucket', text: 'Bitbucket') + - unless bitbucket_import_configured? = render 'bitbucket_import_modal' %div - if gitlab_import_enabled? - - if gitlab_import_configured? - = link_to status_import_gitlab_path, class: 'btn import_gitlab' do - %i.fa.fa-heart - GitLab.com - - else - = link_to status_import_gitlab_path, class: 'how_to_import_link btn import_gitlab' do - %i.fa.fa-heart - GitLab.com + = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do + = icon('gitlab', text: 'GitLab.com') + - unless gitlab_import_configured? = render 'gitlab_import_modal' %div - - if gitorious_import_enabled? - = link_to new_import_gitorious_path, class: 'btn import_gitorious' do - %i.icon-gitorious.icon-gitorious-small - Gitorious.org - %div - if google_code_import_enabled? = link_to new_import_google_code_path, class: 'btn import_google_code' do - %i.fa.fa-google - Google Code + = icon('google', text: 'Google Code') %div - if fogbugz_import_enabled? = link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do - %i.fa.fa-bug - Fogbugz + = icon('bug', text: 'Fogbugz') %div - if git_import_enabled? = link_to "#", class: 'btn js-toggle-button import_git' do - %i.fa.fa-git - %span Repo by URL + = icon('git', text: 'Repo by URL') %div{ class: 'import_gitlab_project' } - if gitlab_project_import_enabled? = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do - %i.fa.fa-gitlab - %span GitLab export + = icon('gitlab', text: 'GitLab export') .js-toggle-content.hide = render "shared/import_form", f: f @@ -159,4 +140,4 @@ $('.import_git').click(function( event ) { $projectImportUrl = $('#project_import_url') $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')) - });
\ No newline at end of file + }); diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index 7c61ba750fe..46b402545cd 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -1,4 +1,6 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form" }, authenticity_token: true do |f| +- supports_slash_commands = note_supports_slash_commands?(@note) + += form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = note_target_fields(@note) @@ -10,8 +12,12 @@ = f.hidden_field :position = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do - = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + = render 'projects/zen', f: f, + attr: :note, + classes: 'note-textarea js-note-text', + placeholder: "Write a comment or drag your files here...", + supports_slash_commands: supports_slash_commands + = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands .error-alert .note-form-actions.clearfix diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/projects/notes/_hints.html.haml index 25466e7562e..6c14f48d41b 100644 --- a/app/views/projects/notes/_hints.html.haml +++ b/app/views/projects/notes/_hints.html.haml @@ -1,8 +1,15 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .comment-toolbar.clearfix .toolbar-text Styling with - = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1 - is supported + = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 + - if supports_slash_commands + and + = link_to 'slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1 + are + - else + is + supported %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file
\ No newline at end of file + Attach a file diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 71da8ac9d7c..73fe6a715fa 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -16,23 +16,52 @@ commented %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - .note-actions - - access = note_max_access_for_user(note) - - if access and not note.system - %span.note-role.hidden-xs= access - - if current_user and not note.system - = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do - = icon('spinner spin') - = icon('smile-o') - - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do - = icon('pencil') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do - = icon('trash-o') + - unless note.system? + .note-actions + - access = note_max_access_for_user(note) + - if access + %span.note-role.hidden-xs= access + + - if note.resolvable? + - can_resolve = can?(current_user, :resolve_note, note) + %resolve-btn{ "project-path" => "#{project_path(note.project)}", + "discussion-id" => "#{note.discussion_id}", + ":note-id" => note.id, + ":resolved" => note.resolved?, + ":can-resolve" => can_resolve, + "resolved-by" => "#{note.resolved_by.try(:name)}", + "v-show" => "#{can_resolve || note.resolved?}", + "inline-template" => true, + "v-ref:note_#{note.id}" => true } + + .note-action-button + = icon("spin spinner", "v-show" => "loading") + %button.line-resolve-btn{ type: "button", + class: ("is-disabled" unless can_resolve), + ":class" => "{ 'is-active': isResolved }", + ":aria-label" => "buttonText", + "@click" => "resolve", + ":title" => "buttonText", + "v-show" => "!loading", + "v-el:button" => true } + + = render "shared/icons/icon_status_success.svg" + + - if current_user + - if note.emoji_awardable? + = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do + = icon('spinner spin') + = icon('smile-o', class: 'link-highlight') + + - if note_editable + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = icon('pencil', class: 'link-highlight') + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do + = icon('trash-o') .note-body{class: note_editable ? 'js-task-list-container' : ''} .note-text.md = preserve do - = note.note_html + = note.redacted_note_html = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - if note_editable = render 'projects/notes/edit_form', note: note diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml index 74538a9723e..8352eba7446 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/projects/notes/_notes_with_form.html.haml @@ -14,9 +14,9 @@ .disabled-comment.text-center .disabled-comment-text.inline Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + = link_to "sign up", new_session_path(:user, redirect_to_referer: 'yes') or - = link_to "login", new_session_path(:user, redirect_to_referer: 'yes') + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') to post a comment :javascript diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index d65faf86d4e..7d421c0e740 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -1,19 +1,28 @@ -.nav-links.sub-nav - %ul{ class: (container_class) } - - if project_nav_tab? :pipelines - = nav_link(controller: :pipelines) do - = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do - %span - Pipelines += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + - if project_nav_tab? :pipelines + = nav_link(controller: :pipelines) do + = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do + %span + Pipelines - - if project_nav_tab? :builds - = nav_link(controller: %w(builds)) do - = link_to project_builds_path(@project), title: 'Builds', class: 'shortcuts-builds' do - %span - Builds + - if project_nav_tab? :builds + = nav_link(controller: %w(builds)) do + = link_to project_builds_path(@project), title: 'Builds', class: 'shortcuts-builds' do + %span + Builds - - if project_nav_tab? :environments - = nav_link(controller: %w(environments)) do - = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do - %span - Environments + - if project_nav_tab? :environments + = nav_link(controller: %w(environments)) do + = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do + %span + Environments + + - if can?(current_user, :read_cycle_analytics, @project) + = nav_link(controller: %w(cycle_analytics)) do + = link_to project_cycle_analytics_path(@project), title: 'Cycle Analytics' do + %span + Cycle Analytics diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 8289aefcde7..d288efc546f 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -9,7 +9,9 @@ = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" - if @pipeline.duration in - = time_interval_in_words @pipeline.duration + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" .pull-right = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do @@ -31,7 +33,7 @@ - if @commit .commit-box.content-block %h3.commit-title - = markdown escape_once(@commit.title), pipeline: :single_line + = markdown(@commit.title, pipeline: :single_line) - if @commit.description.present? %pre.commit-description - = preserve(markdown(escape_once(@commit.description), pipeline: :single_line)) + = preserve(markdown(@commit.description, pipeline: :single_line)) diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 5f466bdbac2..2d1df095bfa 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -36,23 +36,20 @@ = link_to ci_lint_path, class: 'btn btn-default' do %span CI Lint - %ul.content-list.pipelines + %div.content-list.pipelines - stages = @pipelines.stages - if @pipelines.blank? - %li + %div .nothing-here-block No pipelines to show - else .table-holder %table.table.builds - %tbody - %th Status - %th Commit - - stages.each do |stage| - %th.stage - %span.has-tooltip{ title: "#{stage.titleize}" } - = stage.titleize - %th - %th + %thead + %th.col-xs-1.col-sm-1 Status + %th.col-xs-2.col-sm-4 Pipeline + %th.col-xs-2.col-sm-2 Stages + %th.col-xs-2.col-sm-2 + %th.hidden-xs.col-sm-3 = render @pipelines, commit_sha: true, stage: true, allow_retry: true, stages: stages = paginate @pipelines, theme: 'gitlab' diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 5f4ec2e40c8..55202725b9e 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :ref, 'Create for', class: 'control-label' .col-sm-10 - = f.text_field :ref, required: true, tabindex: 2, class: 'form-control' + = f.text_field :ref, required: true, tabindex: 2, class: 'form-control js-branch-name ui-autocomplete-input', autocomplete: :false, id: :ref .help-block Existing branch name, tag .form-actions = f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml new file mode 100644 index 00000000000..7b7fa56d993 --- /dev/null +++ b/app/views/projects/pipelines_settings/_badge.html.haml @@ -0,0 +1,27 @@ +.row{ class: badge.title.gsub(' ', '-') } + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = badge.title.capitalize + .col-lg-9 + .prepend-top-10 + .panel.panel-default + .panel-heading + %b + = badge.title.capitalize + · + = badge.to_html + .pull-right + = render 'shared/ref_switcher', destination: 'badges', align_right: true + .panel-body + .row + .col-md-2.text-center + Markdown + .col-md-10.code.js-syntax-highlight + = highlight('.md', badge.to_markdown) + .row + %hr + .row + .col-md-2.text-center + HTML + .col-md-10.code.js-syntax-highlight + = highlight('.html', badge.to_html) diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml index 228bad36ebd..8c7222bfe3d 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/show.html.haml @@ -77,27 +77,4 @@ %hr .row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - Builds Badge - .col-lg-9 - .prepend-top-10 - .panel.panel-default - .panel-heading - %b Builds badge · - = @build_badge.to_html - .pull-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .panel-body - .row - .col-md-2.text-center - Markdown - .col-md-10.code.js-syntax-highlight - = highlight('.md', @build_badge.to_markdown) - .row - %hr - .row - .col-md-2.text-center - HTML - .col-md-10.code.js-syntax-highlight - = highlight('.html', @build_badge.to_html) + = render partial: 'badge', collection: @badges diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index 978c4dfc5ec..fa8cbf71733 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -14,5 +14,14 @@ Read more about role permissions %strong= link_to "here", help_page_path("user/permissions"), class: "vlink" + .form-group + = f.label :expires_at, 'Access expiration date', class: 'control-label' + .col-sm-10 + .clearable-input + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date' + %i.clear-icon.js-clear-input + .help-block + On this date, the user(s) will automatically lose access to this project. + .form-actions = f.submit 'Add users to project', class: "btn btn-create" diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 9031f01b496..9d063b3081f 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,6 +1,6 @@ - page_title "Members" -.project-members-page.prepend-top-default +.project-members-page.js-project-members-page.prepend-top-default - if can?(current_user, :admin_project_member, @project) .panel.panel-default .panel-heading diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml index 45f8ef89060..37e55dc72a3 100644 --- a/app/views/projects/project_members/update.js.haml +++ b/app/views/projects/project_members/update.js.haml @@ -1,2 +1,3 @@ :plain $("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @project_member))}'); + new gl.MemberExpirationDate(); diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml index 0603a014008..04b19a8c5a7 100644 --- a/app/views/projects/protected_branches/_branches_list.html.haml +++ b/app/views/projects/protected_branches/_branches_list.html.haml @@ -1,26 +1,28 @@ -%h5.prepend-top-0 - Already Protected (#{@protected_branches.size}) -- if @protected_branches.empty? - %p.settings-message.text-center - No branches are protected, protect a branch with the form above. -- else - - can_admin_project = can?(current_user, :admin_project, @project) +.panel.panel-default.protected-branches-list + - if @protected_branches.empty? + .panel-heading + %h3.panel-title + Protected branch (#{@protected_branches.size}) + %p.settings-message.text-center + There are currently no protected branches, protect a branch with the form above. + - else + - can_admin_project = can?(current_user, :admin_project, @project) - %table.table.protected-branches-list - %colgroup - %col{ width: "20%" } - %col{ width: "30%" } - %col{ width: "25%" } - %col{ width: "25%" } - %thead - %tr - %th Branch - %th Last commit - %th Allowed to merge - %th Allowed to push - - if can_admin_project - %th - %tbody - = render partial: @protected_branches, locals: { can_admin_project: can_admin_project } + %table.table.table-bordered + %colgroup + %col{ width: "25%" } + %col{ width: "30%" } + %col{ width: "25%" } + %col{ width: "20%" } + %thead + %tr + %th Protected branch (#{@protected_branches.size}) + %th Last commit + %th Allowed to merge + %th Allowed to push + - if can_admin_project + %th + %tbody + = render partial: @protected_branches, locals: { can_admin_project: can_admin_project } - = paginate @protected_branches, theme: 'gitlab' + = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml new file mode 100644 index 00000000000..e95a3b1b4c3 --- /dev/null +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -0,0 +1,41 @@ += form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| + .panel.panel-default + .panel-heading + %h3.panel-title + Protect a branch + .panel-body + .form-horizontal + = form_errors(@protected_branch) + .form-group + = f.label :name, class: 'col-md-2 text-right' do + Branch: + .col-md-10 + = render partial: "dropdown", locals: { f: f } + .help-block + = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches') + such as + %code *-stable + or + %code production/* + are supported + .form-group + %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' } + Allowed to merge: + .col-md-10 + .merge_access_levels-container + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-merge wide', + dropdown_class: 'dropdown-menu-selectable', + data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) + .form-group + %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } + Allowed to push: + .col-md-10 + .push_access_levels-container + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-push wide', + dropdown_class: 'dropdown-menu-selectable', + data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) + + .panel-footer + = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml index b803d932e67..a9e27df5a87 100644 --- a/app/views/projects/protected_branches/_dropdown.html.haml +++ b/app/views/projects/protected_branches/_dropdown.html.haml @@ -1,17 +1,15 @@ = f.hidden_field(:name) -= dropdown_tag("Protected Branch", - options: { title: "Pick protected branch", toggle_class: 'js-protected-branch-select js-filter-submit', += dropdown_tag('Select branch or create wildcard', + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide', filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], project_id: @project.try(:id) } }) do - %ul.dropdown-footer-list.hidden.protected-branch-select-footer-list + %ul.dropdown-footer-list %li = link_to '#', title: "New Protected Branch", class: "create-new-protected-branch" do - Create new - -:javascript - new ProtectedBranchSelect(); + Create wildcard + %code diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index 498e412235e..0193800dedf 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,5 +1,4 @@ -- url = namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) -%tr +%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } %td = protected_branch.name - if @project.root_ref?(protected_branch.name) @@ -14,16 +13,9 @@ = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) - %td - = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level - = dropdown_tag(protected_branch.merge_access_level.humanize, - options: { title: "Allowed to merge", toggle_class: 'allowed-to-merge', dropdown_class: 'dropdown-menu-selectable merge', - data: { field_name: "allowed_to_merge_#{protected_branch.id}", url: url, id: protected_branch.id, type: "merge_access_level" }}) - %td - = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level - = dropdown_tag(protected_branch.push_access_level.humanize, - options: { title: "Allowed to push", toggle_class: 'allowed-to-push', dropdown_class: 'dropdown-menu-selectable push', - data: { field_name: "allowed_to_push_#{protected_branch.id}", url: url, id: protected_branch.id, type: "push_access_level" }}) + + = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch } + - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning btn-sm pull-right" + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml new file mode 100644 index 00000000000..d6044aacaec --- /dev/null +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -0,0 +1,10 @@ +%td + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level + = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) +%td + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level + = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_branches/index.html.haml b/app/views/projects/protected_branches/index.html.haml index 4efe44c7233..49dcc9a6ba4 100644 --- a/app/views/projects/protected_branches/index.html.haml +++ b/app/views/projects/protected_branches/index.html.haml @@ -14,41 +14,7 @@ %li prevent <strong>anyone</strong> from deleting the branch %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}. .col-lg-9 - %h5.prepend-top-0 - Protect a branch - if can? current_user, :admin_project, @project - = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| - = form_errors(@protected_branch) + = render 'create_protected_branch' - .form-group - = f.label :name, "Branch", class: "label-light" - = render partial: "dropdown", locals: { f: f } - %p.help-block - = link_to "Wildcards", help_page_path('user/project/protected_branches', anchor: "wildcard-protected-branches") - such as - %code *-stable - or - %code production/* - are supported. - - .form-group - = hidden_field_tag 'protected_branch[merge_access_level_attributes][access_level]' - = label_tag "Allowed to merge: ", nil, class: "label-light append-bottom-0" - = dropdown_tag("<Make a selection>", - options: { title: "Allowed to merge", toggle_class: 'allowed-to-merge', - dropdown_class: 'dropdown-menu-selectable', - data: { field_name: "protected_branch[merge_access_level_attributes][access_level]" }}) - - .form-group - = hidden_field_tag 'protected_branch[push_access_level_attributes][access_level]' - = label_tag "Allowed to push: ", nil, class: "label-light append-bottom-0" - = dropdown_tag("<Make a selection>", - options: { title: "Allowed to push", toggle_class: 'allowed-to-push', - dropdown_class: 'dropdown-menu-selectable', - data: { field_name: "protected_branch[push_access_level_attributes][access_level]" }}) - - - = f.submit "Protect", class: "btn-create btn protect-branch-btn", disabled: true - - %hr = render "branches_list" diff --git a/app/views/projects/refs/logs_tree.js.haml b/app/views/projects/refs/logs_tree.js.haml index 8ee2aef0e61..1141168f037 100644 --- a/app/views/projects/refs/logs_tree.js.haml +++ b/app/views/projects/refs/logs_tree.js.haml @@ -5,8 +5,8 @@ :plain var row = $("table.table_#{@hex_path} tr.file_#{hexdigest(file_name)}"); - row.find("td.tree_time_ago").html('#{escape_javascript time_ago_with_tooltip(commit.committed_date)}'); - row.find("td.tree_commit").html('#{escape_javascript render("projects/tree/tree_commit_column", commit: commit)}'); + row.find("td.tree-time-ago").html('#{escape_javascript time_ago_with_tooltip(commit.committed_date)}'); + row.find("td.tree-commit").html('#{escape_javascript render("projects/tree/tree_commit_column", commit: commit)}'); - if @more_log_url :plain diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index 835398b6f98..33d5cbff420 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -1,18 +1,20 @@ +- @no_container = true - page_title "Edit", @tag.name, "Tags" = render "projects/commits/head" -.row-content-block - .oneline - .title - Release notes for tag - %strong #{@tag.name} +%div{ class: container_class } + .sub-header-block.no-bottom-space + .oneline + .title + Release notes for tag + %strong #{@tag.name} + -.prepend-top-default = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f| = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." = render 'projects/notes/hints' .error-alert - .form-actions.prepend-top-default + .prepend-top-default = f.submit 'Save changes', class: 'btn btn-save' = link_to "Cancel", namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-default btn-cancel" diff --git a/app/views/projects/repositories/_download_archive.html.haml b/app/views/projects/repositories/_download_archive.html.haml deleted file mode 100644 index 24658319060..00000000000 --- a/app/views/projects/repositories/_download_archive.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -- ref = ref || nil -- btn_class = btn_class || '' -- split_button = split_button || false -- if split_button == true - %span.btn-group{class: btn_class} - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), class: 'btn col-xs-10', rel: 'nofollow' do - %i.fa.fa-download - %span Download zip - %a.col-xs-2.btn.dropdown-toggle{ 'data-toggle' => 'dropdown' } - %span.caret - %span.sr-only - Select Archive Format - %ul.col-xs-10.dropdown-menu.dropdown-menu-align-right{ role: 'menu' } - %li - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), rel: 'nofollow' do - %i.fa.fa-download - %span Download zip - %li - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'tar.gz'), rel: 'nofollow' do - %i.fa.fa-download - %span Download tar.gz - %li - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'tar.bz2'), rel: 'nofollow' do - %i.fa.fa-download - %span Download tar.bz2 - %li - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'tar'), rel: 'nofollow' do - %i.fa.fa-download - %span Download tar -- else - %span.btn-group{class: btn_class} - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), class: 'btn', rel: 'nofollow' do - %i.fa.fa-download - %span zip - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'tar.gz'), class: 'btn', rel: 'nofollow' do - %i.fa.fa-download - %span tar.gz diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml index 43a6fdfd103..d9c39fb87b7 100644 --- a/app/views/projects/repositories/_feed.html.haml +++ b/app/views/projects/repositories/_feed.html.haml @@ -12,7 +12,7 @@ = link_to namespace_project_commits_path(@project.namespace, @project, commit.id) do %code= commit.short_id = image_tag avatar_icon(commit.author_email), class: "", width: 16, alt: '' - = markdown escape_once(truncate(commit.title, length: 40)), pipeline: :single_line, author: commit.author + = markdown(truncate(commit.title, length: 40), pipeline: :single_line, author: commit.author) %td %span.pull-right.cgray = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index c45a9d4f81f..33a9a96183c 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -5,7 +5,7 @@ .col-sm-10 .checkbox = f.check_box :active - %span.light Paused runners don't accept new builds + %span.light Paused Runners don't accept new builds .form-group = label :run_untagged, 'Run untagged jobs', class: 'control-label' .col-sm-10 @@ -33,6 +33,6 @@ Tags .col-sm-10 = f.text_field :tag_list, value: runner.tag_list.to_s, class: 'form-control' - .help-block You can setup jobs to only use runners with specific tags + .help-block You can setup jobs to only use Runners with specific tags .form-actions = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 85225857758..6e58e5a0c78 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -15,7 +15,7 @@ .pull-right - if @project_runners.include?(runner) - if runner.belongs_to_one_project? - = link_to 'Remove runner', runner_path(runner), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm' + = link_to 'Remove Runner', runner_path(runner), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm' - else - runner_project = @project.runner_projects.find_by(runner_id: runner) = link_to 'Disable for this project', namespace_project_runner_project_path(@project.namespace, @project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm' diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 9fa4127c948..5afa193357e 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -1,24 +1,26 @@ -%h3 Shared runners +%h3 Shared Runners .bs-callout.bs-callout-warning.shared-runners-description - - if shared_runners_text.present? - = markdown(shared_runners_text, pipeline: 'plain_markdown') + - if current_application_settings.shared_runners_text.present? + = markdown_field(current_application_settings, :shared_runners_text) - else - Shared runners execute code of different projects on the same Runner unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is on GitLab.com). + GitLab Shared Runners execute code of different projects on the same Runner + unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is + on GitLab.com). %hr - if @project.shared_runners_enabled? = link_to toggle_shared_runners_namespace_project_runners_path(@project.namespace, @project), class: 'btn btn-warning', method: :post do - Disable shared runners + Disable shared Runners - else = link_to toggle_shared_runners_namespace_project_runners_path(@project.namespace, @project), class: 'btn btn-success', method: :post do - Enable shared runners + Enable shared Runners for this project - if @shared_runners_count.zero? - This GitLab server does not provide any shared runners yet. - Please use specific runners or ask the administrator to create one. + This GitLab server does not provide any shared Runners yet. + Please use the specific Runners or ask your administrator to create one. - else - %h4.underlined-title Available shared runners - #{@shared_runners_count} + %h4.underlined-title Available shared Runners : #{@shared_runners_count} %ul.bordered-list.available-shared-runners = render partial: 'runner', collection: @shared_runners, as: :runner - if @shared_runners_count > 10 diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index d469dda5b81..858af78f7bf 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -1,20 +1,20 @@ -%h3 Specific runners +%h3 Specific Runners .bs-callout.help-callout - %h4 How to setup a new project specific runner + %h4 How to setup a specific Runner for a new project %ol %li - Install GitLab Runner software. - Checkout the #{link_to 'GitLab Runner section', 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'} to install it + Install a Runner compatible with GitLab CI + (checkout the #{link_to 'GitLab Runner section', 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'} for information on how to install it). %li - Specify the following URL during runner setup: + Specify the following URL during the Runner setup: %code #{ci_root_url(only_path: false)} %li Use the following registration token during setup: %code #{@project.runners_token} %li - Start runner! + Start the Runner! - if @project_runners.any? diff --git a/app/views/projects/runners/index.html.haml b/app/views/projects/runners/index.html.haml index 2d5b9f43c24..92957470070 100644 --- a/app/views/projects/runners/index.html.haml +++ b/app/views/projects/runners/index.html.haml @@ -2,24 +2,24 @@ .light.prepend-top-default %p - A 'runner' is a process which runs a build. - You can setup as many runners as you need. + A 'Runner' is a process which runs a build. + You can setup as many Runners as you need. %br Runners can be placed on separate users, servers, and even on your local machine. - %p Each runner can be in one of the following states: + %p Each Runner can be in one of the following states: %div %ul %li %span.label.label-success active - \- runner is active and can process any new build + \- Runner is active and can process any new builds %li %span.label.label-danger paused - \- runner is paused and will not receive any new build + \- Runner is paused and will not receive any new builds %hr -%p.lead To start serving your builds you can either add specific runners to your project or use shared runners +%p.lead To start serving your builds you can either add specific Runners to your project or use shared Runners .row .col-sm-6 = render 'specific_runners' diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index a666d07e9eb..ea4deb6cb28 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -64,14 +64,15 @@ %li.missing = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do Set Up CI + %li.project-repo-buttons-right .project-repo-buttons.project-right-buttons - if current_user = render 'shared/members/access_request_buttons', source: @project + = render "projects/buttons/koding" - .btn-group.project-repo-btn-group - = render "projects/buttons/download" - = render 'projects/buttons/dropdown' + = render 'projects/buttons/download', project: @project, ref: @ref + = render 'projects/buttons/dropdown' = render 'shared/notifications/button', notification_setting: @notification_setting - if @repository.commit @@ -86,4 +87,4 @@ Archived project! Repository is read-only %div{class: "project-show-#{default_project_view}"} - = render default_project_view
\ No newline at end of file + = render default_project_view diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index bdbf3e5f4d6..32e1f8a21b0 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -1,29 +1,29 @@ .hidden-xs - if can?(current_user, :create_project_snippet, @project) - = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-create new-snippet-link', title: "New Snippet" do - New Snippet + = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-create new-snippet-link', title: "New snippet" do + New snippet + - if can?(current_user, :update_project_snippet, @snippet) + = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-danger", title: 'Delete Snippet' do + Delete - if can?(current_user, :update_project_snippet, @snippet) = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped snippable-edit" do Edit - - if can?(current_user, :update_project_snippet, @snippet) - = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-warning", title: 'Delete Snippet' do - Delete - if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet) .visible-xs-block.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } Options - %span.caret + = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul - if can?(current_user, :create_project_snippet, @project) %li - = link_to new_namespace_project_snippet_path(@project.namespace, @project), title: "New Snippet" do - New Snippet - - if can?(current_user, :update_project_snippet, @snippet) - %li - = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet) do - Edit + = link_to new_namespace_project_snippet_path(@project.namespace, @project), title: "New snippet" do + New snippet - if can?(current_user, :update_project_snippet, @snippet) %li = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do Delete + - if can?(current_user, :update_project_snippet, @snippet) + %li + = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet) do + Edit diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index 1646bcf4b8a..e77e1b026f6 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -1,10 +1,9 @@ - page_title "Snippets" .sub-header-block - .pull-right - - if can?(current_user, :create_project_snippet, @project) - = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New Snippet" do - New Snippet + - if can?(current_user, :create_project_snippet, @project) + = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-wide-on-sm pull-right", title: "New snippet" do + New snippet .oneline Share code pastes with others out of git repository diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index bae4d8f349f..9503dbded13 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -1,15 +1,17 @@ - page_title @snippet.title, "Snippets" -.snippet-holder - = render 'shared/snippets/header' += render 'shared/snippets/header' - %article.file-holder.file-holder-no-border.snippet-file-content - .file-title.file-title-clear +.project-snippets + %article.file-holder.snippet-file-content + .file-title = blob_icon 0, @snippet.file_name = @snippet.file_name - .file-actions.hidden-xs + .file-actions = clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{@snippet.id}']") = link_to 'Raw', raw_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-sm", target: "_blank" = render 'shared/snippets/blob' + = render 'award_emoji/awards_block', awardable: @snippet, inline: true + %div#notes= render "projects/notes/notes_with_form" diff --git a/app/views/projects/tags/_download.html.haml b/app/views/projects/tags/_download.html.haml deleted file mode 100644 index 8a11dbfa9f4..00000000000 --- a/app/views/projects/tags/_download.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -%span.btn-group - = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), class: 'btn btn-default', rel: 'nofollow' do - %span Source code - %a.btn.btn-default.dropdown-toggle{ 'data-toggle' => 'dropdown' } - %span.caret - %span.sr-only - Select Archive Format - %ul.dropdown-menu.dropdown-menu-align-right{ role: 'menu' } - %li - = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'zip'), rel: 'nofollow' do - %span Download zip - %li - = link_to archive_namespace_project_repository_path(project.namespace, project, ref: ref, format: 'tar.gz'), rel: 'nofollow' do - %span Download tar.gz diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 2c11c0e5b21..05fccb4f976 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -11,8 +11,7 @@ = strip_gpg_signature(tag.message) .controls - - if can?(current_user, :download_code, @project) - = render 'projects/tags/download', ref: tag.name, project: @project + = render 'projects/buttons/download', project: @project, ref: tag.name - if can?(current_user, :push_code, @project) = link_to edit_namespace_project_tag_release_path(@project.namespace, @project, tag.name), class: 'btn has-tooltip', title: "Edit release notes", data: { container: "body" } do @@ -31,4 +30,4 @@ .description.prepend-top-default .wiki = preserve do - = markdown release.description + = markdown_field(release, :description) diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 368231e73fe..7a0d9dcc94f 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -8,21 +8,24 @@ Tags give the ability to mark specific points in history as being important .nav-controls - - if can? current_user, :push_code, @project - = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do - New tag + = form_tag(filter_tags_path, method: :get) do + = search_field_tag :search, params[:search], { placeholder: 'Filter by tag name', id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } .dropdown.inline %button.dropdown-toggle.btn{ type: 'button', data: { toggle: 'dropdown'} } - %span.light= @sort.humanize - %b.caret + %span.light + = @sort.humanize + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right %li - = link_to namespace_project_tags_path(sort: nil) do + = link_to filter_tags_path(sort: nil) do Name - = link_to namespace_project_tags_path(sort: sort_value_recently_updated) do + = link_to filter_tags_path(sort: sort_value_recently_updated) do = sort_title_recently_updated - = link_to namespace_project_tags_path(sort: sort_value_oldest_updated) do + = link_to filter_tags_path(sort: sort_value_oldest_updated) do = sort_title_oldest_updated + - if can?(current_user, :push_code, @project) + = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do + New tag .tags - if @tags.any? diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 395d7af6cbb..155af755759 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -12,8 +12,7 @@ = icon('files-o') = link_to namespace_project_commits_path(@project.namespace, @project, @tag.name), class: 'btn has-tooltip', title: 'Browse commits' do = icon('history') - - if can? current_user, :download_code, @project - = render 'projects/tags/download', ref: @tag.name, project: @project + = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_project, @project) .pull-right = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: 'btn btn-remove remove-row grouped has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do @@ -34,6 +33,6 @@ .description .wiki = preserve do - = markdown @release.description + = markdown_field(@release, :description) - else This tag has no release notes. diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml index a3a4dba3fa4..ee417b58cbf 100644 --- a/app/views/projects/tree/_blob_item.html.haml +++ b/app/views/projects/tree/_blob_item.html.haml @@ -4,6 +4,6 @@ - file_name = blob_item.name = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)), title: file_name do %span.str-truncated= file_name - %td.tree_time_ago.cgray - = render 'projects/tree/spinner' - %td.hidden-xs.tree_commit + %td.hidden-xs.tree-commit + %td.tree-time-ago.cgray.text-right + = render 'projects/tree/spinner'
\ No newline at end of file diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index baaa2caa6de..a1f4e3e8ed6 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,7 +1,7 @@ %article.file-holder.readme-holder .file-title = blob_icon readme.mode, readme.name - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, @path, readme.name)) do + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do %strong = readme.name .file-content.wiki diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 558e6146ae9..0f7d629ab98 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -4,7 +4,6 @@ %thead %tr %th Name - %th Last Update %th.hidden-xs .pull-left Last Commit .last-commit.hidden-sm.pull-left @@ -14,9 +13,11 @@ %small.light = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace" – - = truncate(@commit.title, length: 50) - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'pull-right' - + = time_ago_with_tooltip(@commit.committed_date) + = @commit.full_title + %small.commit-history-link-spacer | + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'commit-history-link' + %th.text-right Last Update - if @path.present? %tr.tree-item %td.tree-item-file-name diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml index 9577696fc0d..1ccef6d52ab 100644 --- a/app/views/projects/tree/_tree_item.html.haml +++ b/app/views/projects/tree/_tree_item.html.haml @@ -4,6 +4,6 @@ - path = flatten_tree(tree_item) = link_to namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)), title: path do %span.str-truncated= path - %td.tree_time_ago.cgray - = render 'projects/tree/spinner' - %td.hidden-xs.tree_commit + %td.hidden-xs.tree-commit + %td.tree-time-ago.text-right + = render 'projects/tree/spinner'
\ No newline at end of file diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml new file mode 100644 index 00000000000..0a5c6f048f7 --- /dev/null +++ b/app/views/projects/tree/_tree_row.html.haml @@ -0,0 +1,6 @@ +- if tree_row.type == :tree + = render partial: 'projects/tree/tree_item', object: tree_row, as: 'tree_item', locals: { type: 'folder' } +- elsif tree_row.type == :blob + = render partial: 'projects/tree/blob_item', object: tree_row, as: 'blob_item', locals: { type: 'file' } +- elsif tree_row.type == :commit + = render partial: 'projects/tree/submodule_item', object: tree_row, as: 'submodule_item' diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index bf5360b4dee..9864be3562a 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -4,14 +4,13 @@ = content_for :meta_tags do - if current_user = auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, format: :atom, private_token: current_user.private_token), title: "#{@project.name}:#{@ref} commits") -= render 'projects/last_push' = render "projects/commits/head" += render 'projects/last_push' %div{ class: container_class } .tree-controls = render 'projects/find_file_link' - - if can? current_user, :download_code, @project - = render 'projects/repositories/download_archive', ref: @ref, btn_class: 'hidden-xs hidden-sm btn-grouped', split_button: true + = render 'projects/buttons/download', project: @project, ref: @ref #tree-holder.tree-holder.clearfix .nav-block diff --git a/app/views/projects/triggers/index.html.haml b/app/views/projects/triggers/index.html.haml index 7f3de47d7df..f6e0b0a7c8a 100644 --- a/app/views/projects/triggers/index.html.haml +++ b/app/views/projects/triggers/index.html.haml @@ -4,65 +4,89 @@ .col-lg-3 %h4.prepend-top-0 = page_title - %p - Triggers can force a specific branch or tag to rebuild with an API call. + %p.prepend-top-20 + Triggers can force a specific branch or tag to get rebuilt with an API call. + %p.append-bottom-0 + = succeed '.' do + Learn more in the + = link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank' .col-lg-9 - %h5.prepend-top-0 - Your triggers - - if @triggers.any? - .table-responsive - %table.table - %thead - %th Token - %th Last used - %th - = render partial: 'trigger', collection: @triggers, as: :trigger - - else - %p.settings-message.text-center.append-bottom-default - No triggers have been created yet. Add one using the button below. + .panel.panel-default + .panel-heading + %h4.panel-title + Manage your project's triggers + .panel-body + - if @triggers.any? + .table-responsive + %table.table + %thead + %th + %strong Token + %th + %strong Last used + %th + = render partial: 'trigger', collection: @triggers, as: :trigger + - else + %p.settings-message.text-center.append-bottom-default + No triggers have been created yet. Add one using the button below. - = form_for @trigger, url: url_for(controller: 'projects/triggers', action: 'create') do |f| - = f.submit "Add Trigger", class: 'btn btn-success' + = form_for @trigger, url: url_for(controller: 'projects/triggers', action: 'create') do |f| + = f.submit "Add trigger", class: 'btn btn-success' - %h5.prepend-top-default - Use CURL + .panel-footer - %p.light - Copy the token above, set your branch or tag name, and that reference will be rebuilt. + %p + In the following examples, you can see the exact API call you need to + make in order to rebuild a specific + %code ref + (branch or tag) with a trigger token. + %p + All you need to do is replace the + %code TOKEN + and + %code REF_NAME + with the trigger token and the branch or tag name respectively. - %pre - :plain - curl -X POST \ - -F token=TOKEN \ - -F ref=REF_NAME \ - #{builds_trigger_url(@project.id)} - %h5.prepend-top-default - Use .gitlab-ci.yml + %h5.prepend-top-default + Use cURL - %p.light - In the - %code .gitlab-ci.yml - of the dependent project, include the following snippet. - The project will rebuild at the end of the build. + %p.light + Copy one of the tokens above, set your branch or tag name, and that + reference will be rebuilt. - %pre - :plain - trigger: - type: deploy - script: - - "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}" - %h5.prepend-top-default - Pass build variables + %pre + :plain + curl -X POST \ + -F token=TOKEN \ + -F ref=REF_NAME \ + #{builds_trigger_url(@project.id)} + %h5.prepend-top-default + Use .gitlab-ci.yml - %p.light - Add - %code variables[VARIABLE]=VALUE - to an API request. Variable values can be used to distinguish between triggered builds and normal builds. + %p.light + In the + %code .gitlab-ci.yml + of another project, include the following snippet. + The project will be rebuilt at the end of the build. - %pre.append-bottom-0 - :plain - curl -X POST \ - -F token=TOKEN \ - -F "ref=REF_NAME" \ - -F "variables[RUN_NIGHTLY_BUILD]=true" \ - #{builds_trigger_url(@project.id)} + %pre + :plain + trigger_build: + stage: deploy + script: + - "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}" + %h5.prepend-top-default + Pass build variables + + %p.light + Add + %code variables[VARIABLE]=VALUE + to an API request. Variable values can be used to distinguish between triggered builds and normal builds. + + %pre.append-bottom-0 + :plain + curl -X POST \ + -F token=TOKEN \ + -F "ref=REF_NAME" \ + -F "variables[RUN_NIGHTLY_BUILD]=true" \ + #{builds_trigger_url(@project.id)} diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml index 6c43f822db4..07cee86ba4c 100644 --- a/app/views/projects/variables/_table.html.haml +++ b/app/views/projects/variables/_table.html.haml @@ -9,7 +9,7 @@ %th Value %th %tbody - - @project.variables.each do |variable| + - @project.variables.order_key_asc.each do |variable| - if variable.id? %tr %td= variable.key diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 643f7c589e6..6624d5cb427 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -24,7 +24,7 @@ = succeed '.' do More examples are in the - = link_to 'documentation', help_page_path("user/project/markdown", anchor: "wiki-specific-markdown") + = link_to 'documentation', help_page_path("user/markdown", anchor: "wiki-specific-markdown") .form-group = f.label :commit_message, class: 'control-label' diff --git a/app/views/projects/wikis/_nav.html.haml b/app/views/projects/wikis/_nav.html.haml index f8ea479e0b1..09c4411d67e 100644 --- a/app/views/projects/wikis/_nav.html.haml +++ b/app/views/projects/wikis/_nav.html.haml @@ -1,13 +1,16 @@ -.nav-links.sub-nav - %ul{ class: (container_class) } - = nav_link(html_options: {class: params[:id] == 'home' ? 'active' : '' }) do - = link_to 'Home', namespace_project_wiki_path(@project.namespace, @project, :home) += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + = nav_link(html_options: {class: params[:id] == 'home' ? 'active' : '' }) do + = link_to 'Home', namespace_project_wiki_path(@project.namespace, @project, :home) - = nav_link(path: 'wikis#pages') do - = link_to 'Pages', namespace_project_wiki_pages_path(@project.namespace, @project) + = nav_link(path: 'wikis#pages') do + = link_to 'Pages', namespace_project_wiki_pages_path(@project.namespace, @project) - = nav_link(path: 'wikis#git_access') do - = link_to namespace_project_wikis_git_access_path(@project.namespace, @project) do - Git Access + = nav_link(path: 'wikis#git_access') do + = link_to namespace_project_wikis_git_access_path(@project.namespace, @project) do + Git Access - = render 'projects/wikis/new' + = render 'projects/wikis/new' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 252c37532e1..7fe2bce3e7c 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -10,12 +10,16 @@ in group #{link_to @group.name, @group} .results.prepend-top-10 - .search-results - - if @scope == 'projects' - .term - = render 'shared/projects/list', projects: @search_objects - - else - = render partial: "search/results/#{@scope.singularize}", collection: @search_objects + - if @scope == 'commits' + %ul.list-unstyled + = render partial: "search/results/commit", collection: @search_objects + - else + .search-results + - if @scope == 'projects' + .term + = render 'shared/projects/list', projects: @search_objects + - else + = render partial: "search/results/#{@scope.singularize}", collection: @search_objects - if @scope != 'projects' = paginate(@search_objects, theme: 'gitlab') diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index 290743feb4a..6f0a0ea36ec 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,4 +1,4 @@ -- blob = @project.repository.parse_search_result(blob) +- blob = parse_search_result(blob) .blob-result .file-holder .file-title diff --git a/app/views/search/results/_commit.html.haml b/app/views/search/results/_commit.html.haml index 4e6c3965dc6..5b2d83d6b92 100644 --- a/app/views/search/results/_commit.html.haml +++ b/app/views/search/results/_commit.html.haml @@ -1,2 +1 @@ -.search-result-row - = render 'projects/commits/commit', project: @project, commit: commit += render 'projects/commits/commit', project: @project, commit: commit diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index 8f68d6d1b87..e010f21de5a 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -7,7 +7,7 @@ - if issue.description.present? .description.term = preserve do - = search_md_sanitize(markdown(truncate(issue.description, length: 200, separator: " "), { project: issue.project, author: issue.author })) + = search_md_sanitize(issue, :description) %span.light #{issue.project.name_with_namespace} - if issue.closed? diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml index 6331c2bd6b0..07b17bc69c0 100644 --- a/app/views/search/results/_merge_request.html.haml +++ b/app/views/search/results/_merge_request.html.haml @@ -6,7 +6,7 @@ - if merge_request.description.present? .description.term = preserve do - = search_md_sanitize(markdown(merge_request.description, { project: merge_request.project, author: merge_request.author })) + = search_md_sanitize(merge_request, :description) %span.light #{merge_request.project.name_with_namespace} .pull-right diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml index b31595d8d1c..9664f65a36e 100644 --- a/app/views/search/results/_milestone.html.haml +++ b/app/views/search/results/_milestone.html.haml @@ -6,4 +6,4 @@ - if milestone.description.present? .description.term = preserve do - = search_md_sanitize(markdown(milestone.description)) + = search_md_sanitize(milestone, :description) diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index e0400083870..f3701b89bb4 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -23,4 +23,4 @@ .note-search-result .term = preserve do - = search_md_sanitize(markdown(note.note, {no_header_anchors: true, author: note.author})) + = search_md_sanitize(note, :note) diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 235106c4f74..648d0bd76cb 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,4 +1,4 @@ -- wiki_blob = @project.repository.parse_search_result(wiki_blob) +- wiki_blob = parse_search_result(wiki_blob) .blob-result .file-holder .file-title diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml new file mode 100644 index 00000000000..9ce6a1aeef5 --- /dev/null +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -0,0 +1,19 @@ +- noteable = @sent_notification.noteable +- noteable_type = @sent_notification.noteable_type.humanize(capitalize: false) +- noteable_text = %(#{noteable.title} (#{noteable.to_reference})) + +- page_title "Unsubscribe", noteable_text, @sent_notification.noteable_type.humanize.pluralize, @sent_notification.project.name_with_namespace + + +%h3.page-title + Unsubscribe from #{noteable_type} #{noteable_text} + +%p + = succeed '?' do + Are you sure you want to unsubscribe from #{noteable_type} + = link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable]) + +%p + = link_to 'Unsubscribe', unsubscribe_sent_notification_path(@sent_notification, force: true), + class: 'btn btn-primary append-right-10' + = link_to 'Cancel', new_user_session_path, class: 'btn append-right-10' diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml index 8824bcc158e..3480800369a 100644 --- a/app/views/shared/_event_filter.html.haml +++ b/app/views/shared/_event_filter.html.haml @@ -1,4 +1,5 @@ %ul.nav-links.event-filter.scrolling-tabs + = event_filter_link EventFilter.all, 'All' = event_filter_link EventFilter.push, 'Push events' = event_filter_link EventFilter.merged, 'Merge events' = event_filter_link EventFilter.comments, 'Comments' diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 77676454b57..6f593e8dff9 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -12,4 +12,4 @@ = link_to_label(label, tooltip: false) - if label.description %span.label-description - = markdown(label.description, pipeline: :single_line) + = markdown_field(label, :description) diff --git a/app/views/shared/_labels_row.html.haml b/app/views/shared/_labels_row.html.haml index dce492352ac..e324d0e5203 100644 --- a/app/views/shared/_labels_row.html.haml +++ b/app/views/shared/_labels_row.html.haml @@ -1,9 +1,5 @@ - labels.each do |label| %span.label-row.btn-group{ role: "group", aria: { label: label.name }, style: "color: #{text_color_for_bg(label.color)}" } - = link_to label.name, label_filter_path(@project, label, type: controller.controller_name), - class: "btn btn-transparent has-tooltip", - style: "background-color: #{label.color};", - title: escape_once(label.description), - data: { container: "body" } + = link_to_label(label, css_class: 'btn btn-transparent') %button.btn.btn-transparent.label-remove.js-label-filter-remove{ type: "button", style: "background-color: #{label.color};", data: { label: label.title } } = icon("times") diff --git a/app/views/shared/_logo.svg b/app/views/shared/_logo.svg index b07f1c5603e..9b67422da2c 100644 --- a/app/views/shared/_logo.svg +++ b/app/views/shared/_logo.svg @@ -1,9 +1,9 @@ -<svg width="36" height="36" id="tanuki-logo"> - <path id="tanuki-right-ear" class="tanuki-shape" fill="#e24329" d="M2 14l9.38 9v-9l-4-12.28c-.205-.632-1.176-.632-1.38 0z"/> - <path id="tanuki-left-ear" class="tanuki-shape" fill="#e24329" d="M34 14l-9.38 9v-9l4-12.28c.205-.632 1.176-.632 1.38 0z"/> - <path id="tanuki-nose" class="tanuki-shape" fill="#e24329" d="M18,34.38 3,14 33,14 Z"/> - <path id="tanuki-right-eye" class="tanuki-shape" fill="#fc6d26" d="M18,34.38 11.38,14 2,14 6,25Z"/> - <path id="tanuki-left-eye" class="tanuki-shape" fill="#fc6d26" d="M18,34.38 24.62,14 34,14 30,25Z"/> - <path id="tanuki-right-cheek" class="tanuki-shape" fill="#fca326" d="M2 14L.1 20.16c-.18.565 0 1.2.5 1.56l17.42 12.66z"/> - <path id="tanuki-left-cheek" class="tanuki-shape" fill="#fca326" d="M34 14l1.9 6.16c.18.565 0 1.2-.5 1.56L18 34.38z"/> +<svg width="36" height="36" class="tanuki-logo"> + <path class="tanuki-shape tanuki-left-ear" fill="#e24329" d="M2 14l9.38 9v-9l-4-12.28c-.205-.632-1.176-.632-1.38 0z"/> + <path class="tanuki-shape tanuki-right-ear" fill="#e24329" d="M34 14l-9.38 9v-9l4-12.28c.205-.632 1.176-.632 1.38 0z"/> + <path class="tanuki-shape tanuki-nose" fill="#e24329" d="M18,34.38 3,14 33,14 Z"/> + <path class="tanuki-shape tanuki-left-eye" fill="#fc6d26" d="M18,34.38 11.38,14 2,14 6,25Z"/> + <path class="tanuki-shape tanuki-right-eye" fill="#fc6d26" d="M18,34.38 24.62,14 34,14 30,25Z"/> + <path class="tanuki-shape tanuki-left-cheek" fill="#fca326" d="M2 14L.1 20.16c-.18.565 0 1.2.5 1.56l17.42 12.66z"/> + <path class="tanuki-shape tanuki-right-cheek" fill="#fca326" d="M34 14l1.9 6.16c.18.565 0 1.2-.5 1.56L18 34.38z"/> </svg> diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml index cf16c203f9c..73d288e2236 100644 --- a/app/views/shared/_milestones_filter.html.haml +++ b/app/views/shared/_milestones_filter.html.haml @@ -1,10 +1,19 @@ +- if @project + - counts = milestone_counts(@project.milestones) + %ul.nav-links - %li{class: ("active" if params[:state].blank? || params[:state] == 'opened')} + %li{class: milestone_class_for_state(params[:state], 'opened', true)} = link_to milestones_filter_path(state: 'opened') do Open - %li{class: ("active" if params[:state] == 'closed')} + - if @project + %span.badge #{counts[:opened]} + %li{class: milestone_class_for_state(params[:state], 'closed')} = link_to milestones_filter_path(state: 'closed') do Closed - %li{class: ("active" if params[:state] == 'all')} + - if @project + %span.badge #{counts[:closed]} + %li{class: milestone_class_for_state(params[:state], 'all')} = link_to milestones_filter_path(state: 'all') do All + - if @project + %span.badge #{counts[:all]} diff --git a/app/views/shared/_nav_scroll.html.haml b/app/views/shared/_nav_scroll.html.haml new file mode 100644 index 00000000000..4e3b1b3a571 --- /dev/null +++ b/app/views/shared/_nav_scroll.html.haml @@ -0,0 +1,4 @@ +.fade-left + = icon('angle-left') +.fade-right + = icon('angle-right')
\ No newline at end of file diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index 51622931e24..fbbf6f358c5 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -3,7 +3,7 @@ = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' } %a.btn.btn-new.new-project-item-select-button = local_assigns[:label] - %b.caret + = icon('caret-down') :javascript $('.new-project-item-select-button').on('click', function() { diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index ea7162d4d63..9a8252ab087 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -6,7 +6,7 @@ - @options && @options.each do |key, value| = hidden_field_tag key, value, id: nil .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project) }, { toggle_class: "js-project-refs-dropdown" } + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" } .dropdown-menu.dropdown-menu-selectable{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } = dropdown_title "Switch branch/tag" = dropdown_filter "Search branches and tags" diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml index 249bce926ce..68e05cb72e1 100644 --- a/app/views/shared/_sort_dropdown.html.haml +++ b/app/views/shared/_sort_dropdown.html.haml @@ -5,29 +5,29 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - %b.caret + = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-sort %li - = link_to page_filter_path(sort: sort_value_priority) do + = link_to page_filter_path(sort: sort_value_priority, label: true) do = sort_title_priority - = link_to page_filter_path(sort: sort_value_recently_created) do + = link_to page_filter_path(sort: sort_value_recently_created, label: true) do = sort_title_recently_created - = link_to page_filter_path(sort: sort_value_oldest_created) do + = link_to page_filter_path(sort: sort_value_oldest_created, label: true) do = sort_title_oldest_created - = link_to page_filter_path(sort: sort_value_recently_updated) do + = link_to page_filter_path(sort: sort_value_recently_updated, label: true) do = sort_title_recently_updated - = link_to page_filter_path(sort: sort_value_oldest_updated) do + = link_to page_filter_path(sort: sort_value_oldest_updated, label: true) do = sort_title_oldest_updated - = link_to page_filter_path(sort: sort_value_milestone_soon) do + = link_to page_filter_path(sort: sort_value_milestone_soon, label: true) do = sort_title_milestone_soon - = link_to page_filter_path(sort: sort_value_milestone_later) do + = link_to page_filter_path(sort: sort_value_milestone_later, label: true) do = sort_title_milestone_later - if controller.controller_name == 'issues' || controller.action_name == 'issues' - = link_to page_filter_path(sort: sort_value_due_date_soon) do + = link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do = sort_title_due_date_soon - = link_to page_filter_path(sort: sort_value_due_date_later) do + = link_to page_filter_path(sort: sort_value_due_date_later, label: true) do = sort_title_due_date_later - = link_to page_filter_path(sort: sort_value_upvotes) do + = link_to page_filter_path(sort: sort_value_upvotes, label: true) do = sort_title_upvotes - = link_to page_filter_path(sort: sort_value_downvotes) do + = link_to page_filter_path(sort: sort_value_downvotes, label: true) do = sort_title_downvotes diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml index 107ad19177c..b11257ee0e6 100644 --- a/app/views/shared/_visibility_level.html.haml +++ b/app/views/shared/_visibility_level.html.haml @@ -1,12 +1,12 @@ .form-group.project-visibility-level-holder = f.label :visibility_level, class: 'control-label' do Visibility Level - = link_to "(?)", help_page_path("public_access/public_access") + = link_to icon('question-circle'), help_page_path("public_access/public_access") .col-sm-10 - if can_change_visibility_level = render('shared/visibility_radios', model_method: :visibility_level, form: f, selected_level: visibility_level, form_model: form_model) - else - .col-sm-10 + %div %span.info = visibility_level_icon(visibility_level) %strong diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml index ebe2eb0433d..182c4eebd50 100644 --- a/app/views/shared/_visibility_radios.html.haml +++ b/app/views/shared/_visibility_radios.html.haml @@ -10,6 +10,6 @@ .option-descr = visibility_level_description(level, form_model) - unless restricted_visibility_levels.empty? - .col-sm-10 + %div %span.info Some visibility level settings have been restricted by the administrator. diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml new file mode 100644 index 00000000000..60353aee7f1 --- /dev/null +++ b/app/views/shared/builds/_tabs.html.haml @@ -0,0 +1,24 @@ +%ul.nav-links + %li{ class: ('active' if scope.nil?) } + = link_to build_path_proc.call(nil) do + All + %span.badge.js-totalbuilds-count + = number_with_delimiter(all_builds.count(:id)) + + %li{ class: ('active' if scope == 'pending') } + = link_to build_path_proc.call('pending') do + Pending + %span.badge + = number_with_delimiter(all_builds.pending.count(:id)) + + %li{ class: ('active' if scope == 'running') } + = link_to build_path_proc.call('running') do + Running + %span.badge + = number_with_delimiter(all_builds.running.count(:id)) + + %li{ class: ('active' if scope == 'finished') } + = link_to build_path_proc.call('finished') do + Finished + %span.badge + = number_with_delimiter(all_builds.finished.count(:id)) diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 1ad95351005..dc4ee3074d2 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -35,4 +35,4 @@ - if group.description.present? .description - = markdown(group.description, pipeline: :description) + = markdown_field(group, :description) diff --git a/app/views/shared/icons/_icon_cycle_analytics_splash.svg b/app/views/shared/icons/_icon_cycle_analytics_splash.svg new file mode 100644 index 00000000000..eb5a962d651 --- /dev/null +++ b/app/views/shared/icons/_icon_cycle_analytics_splash.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 99 102" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="0" d="m35.12 56.988c4.083-4.385 5.968-12.155 5.968-24.04 0-20.2-15.874-32.16-15.874-32.16-1.114-.954-2.929-.979-4.04 0 0 0-15.874 11.957-15.874 32.16 0 11.882 1.884 19.652 5.968 24.04h23.848"/><mask id="1" width="35.783" height="56.924" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(0-4)"><g transform="translate(32.15 3.976)"><g fill="#6b4fbb"><path d="m11.928 56.988l1.325-1.325v3.313c0 .737.59 1.325 1.325 1.325h17.229c.736 0 1.325-.59 1.325-1.325v-3.313l1.325 1.325h-22.53m22.53-1.325v3.313c0 1.464-1.18 2.651-2.651 2.651h-17.229c-1.464 0-2.651-1.178-2.651-2.651v-3.313h22.53m-5.964 7.361h.663c0 3.294-2.67 5.964-5.964 5.964-3.294 0-5.964-2.67-5.964-5.964h.663.663c0 2.562 2.077 4.639 4.639 4.639 2.562 0 4.639-2.077 4.639-4.639h.663"/><path d="m5.816 42.535c-.346-2.839-.515-6.03-.515-9.584 0-20.2 15.874-32.16 15.874-32.16 1.106-.979 2.921-.954 4.04 0 0 0 15.874 11.957 15.874 32.16 0 11.882-1.884 19.652-5.968 24.04h-23.848c-2.861-3.073-4.643-7.807-5.453-14.453-.06-.493-.115-.997-.164-1.511l-4.04 2.884c-.891.637-1.614 2.041-1.614 3.137v14.581c0 1.465.971 1.958 2.165 1.106l8.691-6.208c-.282-.332-.553-.681-.813-1.048l-8.648 6.177c-.147.105-.069.152-.069-.027v-14.581c0-.668.516-1.671 1.059-2.059l3.432-2.451m38.4 20.2c1.193.852 2.165.359 2.165-1.106v-14.581c0-1.096-.723-2.5-1.614-3.137l-4.04-2.884c-.049.514-.104 1.018-.164 1.511l3.432 2.451c.543.388 1.059 1.391 1.059 2.059v14.581c0 .179.078.132-.069.027l-8.648-6.177c-.26.367-.531.716-.813 1.048l8.691 6.208"/></g><use fill="#fff" stroke="#6b4fbb" stroke-width="2.651" mask="url(#1)" xlink:href="#0"/><g fill="#b5a7dd"><path d="m30.482 28.494c0-4.03-3.263-7.289-7.289-7.289-4.03 0-7.289 3.263-7.289 7.289 0 4.03 3.263 7.289 7.289 7.289 4.03 0 7.289-3.263 7.289-7.289m-15.904 0c0-4.758 3.857-8.614 8.614-8.614 4.758 0 8.614 3.857 8.614 8.614 0 4.758-3.857 8.614-8.614 8.614-4.758 0-8.614-3.857-8.614-8.614"/><path d="m27.17 28.494c0-2.196-1.78-3.976-3.976-3.976-2.196 0-3.976 1.78-3.976 3.976 0 2.196 1.78 3.976 3.976 3.976 2.196 0 3.976-1.78 3.976-3.976m-9.277 0c0-2.928 2.373-5.301 5.301-5.301 2.928 0 5.301 2.373 5.301 5.301 0 2.928-2.373 5.301-5.301 5.301-2.928 0-5.301-2.373-5.301-5.301"/></g><path fill="#6b4fbb" d="m34.458 87.47c0 1.098.89 1.988 1.988 1.988 1.098 0 1.988-.89 1.988-1.988 0-.366.297-.663.663-.663.366 0 .663.297.663.663 0 1.83-1.483 3.313-3.313 3.313-1.826 0-3.307-1.478-3.313-3.302 0-.002 0-.003 0-.005v-2.663c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.657m-21.2-6.615c0-.002 0-.003 0-.005v-2.663c0-.358-.297-.657-.663-.657-.369 0-.663.294-.663.657v2.657c0 1.098-.89 1.988-1.988 1.988-1.098 0-1.988-.89-1.988-1.988 0-.366-.297-.663-.663-.663-.366 0-.663.297-.663.663 0 1.83 1.483 3.313 3.313 3.313 1.826 0 3.307-1.477 3.313-3.302m5.301 7.285c0-.001 0-.002 0-.003v-16.576c0-.362-.297-.658-.663-.658-.369 0-.663.295-.663.658v16.571c0 2.01-1.632 3.645-3.645 3.645-2.01 0-3.645-1.632-3.645-3.645 0-.366-.297-.663-.663-.663-.366 0-.663.297-.663.663 0 2.745 2.225 4.97 4.97 4.97 2.742 0 4.966-2.221 4.97-4.963m10.602 8.607v-18.555c0-.365-.297-.661-.663-.661-.369 0-.663.296-.663.661v18.557c0 0 0 0 0 .001.001 2.744 2.226 4.968 4.97 4.968 2.745 0 4.97-2.225 4.97-4.97 0-.366-.297-.663-.663-.663-.366 0-.663.297-.663.663 0 2.01-1.632 3.645-3.645 3.645-2.01 0-3.645-1.632-3.645-3.645m3.976-25.19c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663m0 6.627c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663m-10.602-6.627c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663m5.301 0c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663m-5.301 6.627c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663m0 6.627c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663m-10.602-13.253c0-.363.294-.657.663-.657.366 0 .663.299.663.657v2.663c0 .363-.294.657-.663.657-.366 0-.663-.299-.663-.657v-2.663"/></g><path fill="#e2ddf2" d="m97.75 76.54c0-2.745-2.225-4.97-4.97-4.97-2.745 0-4.97 2.225-4.97 4.97 0 2.745 2.225 4.97 4.97 4.97 2.745 0 4.97-2.225 4.97-4.97m-8.614 0c0-2.01 1.632-3.645 3.645-3.645 2.01 0 3.645 1.632 3.645 3.645 0 2.01-1.632 3.645-3.645 3.645-2.01 0-3.645-1.632-3.645-3.645m-60.964-57.651c0-2.745-2.225-4.97-4.97-4.97-2.745 0-4.97 2.225-4.97 4.97 0 2.745 2.225 4.97 4.97 4.97 2.745 0 4.97-2.225 4.97-4.97m-8.614 0c0-2.01 1.632-3.645 3.645-3.645 2.01 0 3.645 1.632 3.645 3.645 0 2.01-1.632 3.645-3.645 3.645-2.01 0-3.645-1.632-3.645-3.645"/><path fill="#b5a7dd" d="m98.41 34.458c0-1.83-1.483-3.313-3.313-3.313-1.83 0-3.313 1.483-3.313 3.313 0 1.83 1.483 3.313 3.313 3.313 1.83 0 3.313-1.483 3.313-3.313m-5.301 0c0-1.098.89-1.988 1.988-1.988 1.098 0 1.988.89 1.988 1.988 0 1.098-.89 1.988-1.988 1.988-1.098 0-1.988-.89-1.988-1.988m-86.14 20.542c0-1.83-1.483-3.313-3.313-3.313-1.83 0-3.313 1.483-3.313 3.313 0 1.83 1.483 3.313 3.313 3.313 1.83 0 3.313-1.483 3.313-3.313m-5.301 0c0-1.098.89-1.988 1.988-1.988 1.098 0 1.988.89 1.988 1.988 0 1.098-.89 1.988-1.988 1.988-1.098 0-1.988-.89-1.988-1.988"/></g></svg> diff --git a/app/views/shared/icons/_icon_empty_groups.svg b/app/views/shared/icons/_icon_empty_groups.svg new file mode 100644 index 00000000000..9228be05f03 --- /dev/null +++ b/app/views/shared/icons/_icon_empty_groups.svg @@ -0,0 +1 @@ +<svg width="249" height="368" viewBox="891 156 249 368" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="131" height="162" rx="10"/><mask id="e" x="0" y="0" width="131" height="162" fill="#fff"><use xlink:href="#a"/></mask><path d="M223.616 127.958V108.96c0-4.416-3.584-8-8.005-8h-23.985c-2.778 0-5.98 2.014-7.18 4.5l-5.07 10.5h-49.763c-5.527 0-9.996 4.475-9.996 9.997v53.005c0 5.513 4.475 9.997 9.996 9.997h84.01c5.525 0 9.994-4.477 9.994-9.998v-51.004z" id="b"/><mask id="f" x="0" y="0" width="104" height="88" fill="#fff"><use xlink:href="#b"/></mask><path d="M47 25h.996C53.52 25 58 29.472 58 34.99v20.02C58 60.526 53.52 65 47.996 65H10.004C4.48 65 0 60.528 0 55.01V34.99C0 29.474 4.48 25 10.004 25H11v-7c0-9.94 8.06-18 18-18s18 8.06 18 18v7zm-6 0H17v-7c0-6.627 5.373-12 12-12s12 5.373 12 12v7z" id="c"/><mask id="g" x="0" y="0" width="58" height="65" fill="#fff"><use xlink:href="#c"/></mask><path d="M0 10.008C0 4.48 4.476 0 10 0h218c5.523 0 10 4.473 10 10.008v140.94c0 5.53-4.062 11.882-9.08 14.196l-100.84 46.5c-5.015 2.31-13.142 2.312-18.16 0l-100.84-46.5C4.064 162.832 0 156.484 0 150.95V10.007z" id="d"/><mask id="h" x="0" y="0" width="238" height="213.417" fill="#fff"><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(891 156)"><g transform="rotate(8 -266.528 490.3)"><use stroke="#E5E5E5" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#a"/><rect fill="#FC8A51" x="20" y="31" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="60" y="31" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="36" y="31" width="20" height="4" rx="2"/><rect fill="#6B4FBB" x="20" y="65" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="44" y="65" width="20" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="80" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="80" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="48" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="60" y="80" width="12" height="4" rx="2"/><rect fill="#6B4FBB" x="52" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="68" y="48" width="12" height="4" rx="2"/></g><use stroke="#B5A7DD" mask="url(#f)" stroke-width="8" fill="#FFF" transform="rotate(5 171.616 144.96)" xlink:href="#b"/><path d="M58 132c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#C1E7D0"/><path d="M90.143 132c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M74.686 133.875l-3.18-3.18c-.29-.29-.77-.296-1.06-.005l-1.55 1.55c-.287.287-.29.766.004 1.06l4.92 4.92c.504.504 1.32.504 1.823 0l.654-.653 7.804-7.804c.3-.3.29-.77-.005-1.067l-1.578-1.58c-.302-.3-.775-.298-1.068-.004l-6.764 6.763z" fill="#31AF64"/><path d="M4 66c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18S4 75.94 4 66z" fill="#D5ECF7"/><path d="M36.143 66c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M22 55.714c5.68 0 10.286 4.605 10.286 10.286 0 5.68-4.605 10.286-10.286 10.286-3.45 0-6.505-1.7-8.37-4.307L22 66V55.714z" fill="#2D9FD8"/><g transform="rotate(-8 748.533 18.147)"><use stroke="#FDE5D8" mask="url(#g)" stroke-width="8" fill="#FFF" xlink:href="#c"/><path d="M31 46.584c1.766-.772 3-2.534 3-4.584 0-2.76-2.24-5-5-5s-5 2.24-5 5c0 2.05 1.234 3.812 3 4.584v3.42c0 1.1.895 1.996 2 1.996 1.112 0 2-.894 2-1.997v-3.42z" fill="#FC8A51"/></g><g transform="translate(0 154)"><use stroke="#E5E5E5" mask="url(#h)" stroke-width="8" fill="#FFF" xlink:href="#d"/><g opacity=".3"><path d="M141.837 104.53l-2.56-7.993-5.074-15.843c-.26-.815-1.398-.815-1.66 0l-5.074 15.843h-16.85l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33 22.16-16.33c.61-.452.866-1.25.632-1.98" fill="#A1A1A1"/><path fill="#5C5C5C" d="M119.044 122.84l8.425-26.303h-16.85l8.424 26.304"/><path fill="#787878" d="M119.044 122.84l-8.425-26.303H98.81l20.232 26.304"/><path fill="#787878" d="M119.044 122.84l8.425-26.303h11.807l-20.233 26.304"/><path d="M98.812 96.537l-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33L98.81 96.538z" fill="#A1A1A1"/><path d="M98.812 96.537h11.807l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843z" fill="#5C5C5C"/><path d="M139.277 96.537l2.56 7.993c.234.73-.022 1.528-.634 1.98l-22.16 16.33 20.234-26.303z" fill="#A1A1A1"/><path d="M139.277 96.537H127.47l5.074-15.843c.26-.815 1.398-.815 1.66 0l5.073 15.843z" fill="#5C5C5C"/></g><path d="M57 18.29c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H41c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H77c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm17 24.693c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm202 32.923c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm202 32.923c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm-202 0c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm202 32.922c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm179.023 19.555c-.988.452-1.388 1.55-.894 2.454.493.904 1.694 1.27 2.682.82l14.31-6.545c.99-.452 1.39-1.55.896-2.454-.494-.902-1.696-1.27-2.684-.817l-14.31 6.544zm-32.2 14.723c-.987.452-1.388 1.55-.894 2.454.493.904 1.695 1.27 2.683.818l14.31-6.544c.99-.45 1.39-1.55.895-2.454-.494-.903-1.695-1.27-2.683-.818l-14.31 6.544zm-32.2 14.724c-.987.45-1.387 1.55-.893 2.454.494.903 1.695 1.27 2.683.818l14.31-6.544c.99-.452 1.39-1.55.896-2.454-.495-.904-1.697-1.27-2.685-.818l-14.31 6.544zm-23.67-2.023l-12.186-5.57c-.987-.452-2.19-.086-2.683.817-.494.904-.093 2.003.895 2.454l12.185 5.573c.754.345 1.57.645 2.438.898 1.052.307 2.177-.224 2.513-1.187.335-.962-.246-1.99-1.298-2.298-.677-.197-1.302-.426-1.864-.684zM62.57 168.437c-.988-.452-2.19-.086-2.683.818-.494.903-.094 2.002.894 2.454l14.31 6.544c.988.45 2.19.085 2.683-.818.494-.904.094-2.003-.894-2.454l-14.312-6.544zm-32.2-14.723c-.988-.452-2.19-.086-2.683.818-.494.904-.093 2.003.895 2.454l14.31 6.544c.988.452 2.19.086 2.684-.818.494-.903.093-2.002-.895-2.454l-14.312-6.543z" fill="#EEE"/></g><g><path d="M104 18c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#FADFD9"/><path d="M136.143 18c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M119.43 8.994c0-.707.57-1.28 1.283-1.28h2.574c.71 0 1.284.57 1.284 1.28v10.298c0 .706-.57 1.28-1.283 1.28h-2.574c-.71 0-1.284-.57-1.284-1.28V8.994zm0 15.433c0-.71.57-1.284 1.283-1.284h2.574c.71 0 1.284.57 1.284 1.284V27c0 .71-.57 1.286-1.283 1.286h-2.574c-.71 0-1.284-.57-1.284-1.285v-2.573z" fill="#E75E40"/></g><g><path d="M213 89c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#F6D4DC"/><path d="M245.143 89c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M231 86.348l-3.603-3.602c-.288-.29-.766-.286-1.063.01l-1.578 1.578c-.3.302-.3.773-.01 1.063L228.348 89l-3.602 3.603c-.29.288-.286.766.01 1.063l1.578 1.578c.302.3.773.3 1.063.01L231 91.652l3.603 3.602c.288.29.766.286 1.063-.01l1.578-1.578c.3-.302.3-.773.01-1.063L233.652 89l3.602-3.603c.29-.288.286-.766-.01-1.063l-1.578-1.578c-.302-.3-.773-.3-1.063-.01L231 86.348z" fill="#D22852"/></g></g></svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_icon_fork.svg b/app/views/shared/icons/_icon_fork.svg index a21f8f3a951..ce22b6cdaea 100644 --- a/app/views/shared/icons/_icon_fork.svg +++ b/app/views/shared/icons/_icon_fork.svg @@ -1,3 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"> - <path fill="#7E7E7E" fill-rule="evenodd" d="M22,29.5351288 L22,22.7193602 C26.1888699,21.5098039 29.3985457,16.802989 29.3985457,16.802989 C29.740988,16.3567547 30,15.5559546 30,15.0081969 L30,10.4648712 C31.1956027,9.77325238 32,8.48056471 32,7 C32,4.790861 30.209139,3 28,3 C25.790861,3 24,4.790861 24,7 C24,8.48056471 24.8043973,9.77325238 26,10.4648712 L26,14.7083871 C26,14.8784435 25.9055559,15.0987329 25.7890533,15.2104147 C25.7890533,15.2104147 24.5373893,16.4126202 23.9488702,16.9515733 C22.5015398,18.2770075 21.1191354,19 20.090554,19 C19.0477772,19 17.6172728,18.2608988 16.1128852,16.9142923 C15.5030182,16.3683886 14.3672121,15.3403307 14.3672121,15.3403307 C14.1659605,15.1583364 14.0000086,14.7846305 14.0000192,14.5088473 C14.0000192,14.5088473 14.0000932,12.7539451 14.0001308,10.4647956 C15.1956614,9.77315812 16,8.48051074 16,7 C16,4.790861 14.209139,3 12,3 C9.790861,3 8,4.790861 8,7 C8,8.48056471 8.80439726,9.77325238 10,10.4648712 L10,15.0081969 C10,15.5446944 10.2736352,16.3534183 10.6111812,16.7893819 C10.6111812,16.7893819 13.8599776,21.3779363 18,22.6668724 L18,29.5351288 C16.8043973,30.2267476 16,31.5194353 16,33 C16,35.209139 17.790861,37 20,37 C22.209139,37 24,35.209139 24,33 C24,31.5194353 23.1956027,30.2267476 22,29.5351288 Z M14,7 C14,5.8954305 13.1045695,5 12,5 C10.8954305,5 10,5.8954305 10,7 C10,8.1045695 10.8954305,9 12,9 C13.1045695,9 14,8.1045695 14,7 Z M30,7 C30,5.8954305 29.1045695,5 28,5 C26.8954305,5 26,5.8954305 26,7 C26,8.1045695 26.8954305,9 28,9 C29.1045695,9 30,8.1045695 30,7 Z M22,33 C22,31.8954305 21.1045695,31 20,31 C18.8954305,31 18,31.8954305 18,33 C18,34.1045695 18.8954305,35 20,35 C21.1045695,35 22,34.1045695 22,33 Z"/> -</svg> +<svg xmlns="http://www.w3.org/2000/svg" width="30" height="40" viewBox="5 0 30 40"><path fill="#7E7E7E" fill-rule="evenodd" d="M22 29.535V22.72c4.19-1.21 7.4-5.917 7.4-5.917.34-.446.6-1.247.6-1.795v-4.543C31.196 9.773 32 8.48 32 7c0-2.21-1.79-4-4-4s-4 1.79-4 4c0 1.48.804 2.773 2 3.465v4.243c0 .17-.094.39-.21.502 0 0-1.253 1.203-1.84 1.742C22.5 18.277 21.12 19 20.09 19c-1.042 0-2.473-.74-3.977-2.086-.61-.546-1.746-1.574-1.746-1.574-.2-.182-.367-.555-.367-.83v-4.045C15.196 9.773 16 8.48 16 7c0-2.21-1.79-4-4-4S8 4.79 8 7c0 1.48.804 2.773 2 3.465v4.543c0 .537.274 1.345.61 1.78 0 0 3.25 4.59 7.39 5.88v6.867c-1.196.692-2 1.984-2 3.465 0 2.21 1.79 4 4 4s4-1.79 4-4c0-1.48-.804-2.773-2-3.465zM14 7c0-1.105-.895-2-2-2s-2 .895-2 2 .895 2 2 2 2-.895 2-2zm16 0c0-1.105-.895-2-2-2s-2 .895-2 2 .895 2 2 2 2-.895 2-2zm-8 26c0-1.105-.895-2-2-2s-2 .895-2 2 .895 2 2 2 2-.895 2-2z"/></svg> diff --git a/app/views/shared/icons/_icon_no_wrap.svg b/app/views/shared/icons/_icon_no_wrap.svg new file mode 100644 index 00000000000..fe34cada002 --- /dev/null +++ b/app/views/shared/icons/_icon_no_wrap.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="m6 11h-4.509c-.263 0-.491.226-.491.505v.991c0 .291.22.505.491.505h4.509v.679c0 .301.194.413.454.236l2.355-1.607c.251-.171.259-.442 0-.619l-2.355-1.607c-.251-.171-.454-.07-.454.236v.681m-5-7.495c0-.279.22-.505.498-.505h13c.275 0 .498.214.498.505v.991c0 .279-.22.505-.498.505h-13c-.275 0-.498-.214-.498-.505v-.991m10 8c0-.279.215-.505.49-.505h3.02c.271 0 .49.214.49.505v.991c0 .279-.215.505-.49.505h-3.02c-.271 0-.49-.214-.49-.505v-.991m-10-4c0-.279.22-.505.498-.505h13c.275 0 .498.214.498.505v.991c0 .279-.22.505-.498.505h-13c-.275 0-.498-.214-.498-.505v-.991"/> +</svg> diff --git a/app/views/shared/icons/_icon_play.svg b/app/views/shared/icons/_icon_play.svg new file mode 100644 index 00000000000..e965afa9a56 --- /dev/null +++ b/app/views/shared/icons/_icon_play.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 11" class="icon-play"> + <path fill-rule="evenodd" d="m9.283 6.47l-7.564 4.254c-.949.534-1.719.266-1.719-.576v-9.292c0-.852.756-1.117 1.719-.576l7.564 4.254c.949.534.963 1.392 0 1.934"/> + </svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_icon_soft_wrap.svg b/app/views/shared/icons/_icon_soft_wrap.svg new file mode 100644 index 00000000000..ea27a2024b1 --- /dev/null +++ b/app/views/shared/icons/_icon_soft_wrap.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="m12 11h-2v-.681c0-.307-.203-.407-.454-.236l-2.355 1.607c-.259.177-.251.448 0 .619l2.355 1.607c.259.177.454.065.454-.236v-.679h2c0 0 0 0 0 0 1.657 0 3-1.343 3-3 0-.828-.336-1.578-.879-2.121-.543-.543-1.293-.879-2.121-.879-.001 0-.002 0-.002 0h-10.497c-.271 0-.5.226-.5.505v.991c0 .291.224.505.5.505h10.497c.001 0 .002 0 .002 0 .552 0 1 .448 1 1 0 .276-.112.526-.293.707-.181.181-.431.293-.707.293m-11-7.495c0-.279.22-.505.498-.505h13c.275 0 .498.214.498.505v.991c0 .279-.22.505-.498.505h-13c-.275 0-.498-.214-.498-.505v-.991m0 8c0-.279.215-.505.49-.505h3.02c.271 0 .49.214.49.505v.991c0 .279-.215.505-.49.505h-3.02c-.271 0-.49-.214-.49-.505v-.991"/> +</svg> diff --git a/app/views/shared/icons/_icon_status_created.svg b/app/views/shared/icons/_icon_status_created.svg new file mode 100644 index 00000000000..1f5c3b51b03 --- /dev/null +++ b/app/views/shared/icons/_icon_status_created.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" enable-background="new 0 0 14 14"><path d="M12.5,7 C12.5,4 10,1.5 7,1.5 C4,1.5 1.5,4 1.5,7 C1.5,10 4,12.5 7,12.5 C10,12.5 12.5,10 12.5,7 L12.5,7 Z M0,7 C0,3.1 3.1,0 7,0 C10.9,0 14,3.1 14,7 C14,10.9 10.9,14 7,14 C3.1,14 0,10.9 0,7 L0,7 Z" /><circle cx="7" cy="7" r="3.25"/></svg> diff --git a/app/views/shared/icons/_illustration_no_commits.svg b/app/views/shared/icons/_illustration_no_commits.svg new file mode 100644 index 00000000000..4f9d9add60d --- /dev/null +++ b/app/views/shared/icons/_illustration_no_commits.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 107" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="#eee" fill-rule="evenodd"><path d="m4.01 2h1.102c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-1.102c-2.218 0-4.01 1.788-4.01 4 0 .552.448 1 1 1 .552 0 1-.448 1-1 0-1.108.892-2 2.01-2m12.702 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m8.088 0c.822 0 1.554.503 1.86 1.254.208.512.791.758 1.303.55.512-.208.758-.791.55-1.303-.609-1.497-2.069-2.5-3.712-2.5h-2.188c-.552 0-1 .448-1 1 0 .552.448 1 1 1h2.188m2.01 12.518c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 11.6c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 11.6c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 6.282c0 1.108-.892 2-2.01 2h-.72c-.552 0-1 .448-1 1 0 .552.448 1 1 1h.72c2.218 0 4.01-1.788 4.01-4v-.382c0-.552-.448-1-1-1-.552 0-1 .448-1 1v.382m-14.325 2c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-8.47 0c-.755 0-1.438-.424-1.782-1.085-.255-.49-.859-.681-1.349-.426-.49.255-.681.859-.426 1.349.684 1.316 2.046 2.162 3.556 2.162h2.57c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-2.57m-2.01-12.136c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-11.6c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-11.6c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-6.664c0-.552-.448-1-1-1-.552 0-1 .448-1 1v.764c0 .552.448 1 1 1 .552 0 1-.448 1-1v-.764" id="0"/><circle cx="21" cy="24" r="10"/><rect width="33" height="3" x="37" y="18" rx="1.5" id="1"/><rect width="53" height="3" x="37" y="27" rx="1.5" id="2"/><path d="m131 29c0 .552.447.999.996.999h22.01c.545 0 .996-.451.996-.999v-9c0-.552-.447-.999-.996-.999h-22.01c-.545 0-.996.451-.996.999v9m.996-12h22.01c1.655 0 2.996 1.344 2.996 2.999v9c0 1.657-1.35 2.999-2.996 2.999h-22.01c-1.655 0-2.996-1.344-2.996-2.999v-9c0-1.657 1.35-2.999 2.996-2.999" id="3"/><g transform="translate(0 59)"><use xlink:href="#0"/><circle cx="21" cy="24" r="10"/><use xlink:href="#1"/><use xlink:href="#2"/><use xlink:href="#3"/></g></g></svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg new file mode 100644 index 00000000000..43559a60cb0 --- /dev/null +++ b/app/views/shared/icons/_next_discussion.svg @@ -0,0 +1 @@ +<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg> diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 0b7fa8c7d06..31620297be0 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -1,9 +1,12 @@ +- finder = controller.controller_name == 'issues' || controller.controller_name == 'boards' ? issues_finder : merge_requests_finder +- boards_page = controller.controller_name == 'boards' + .issues-filters .issues-details-filters.row-content-block.second-block - = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :issue_search]), method: :get, class: 'filter-form js-filter-form' do - - if params[:issue_search].present? - = hidden_field_tag :issue_search, params[:issue_search] - - if controller.controller_name == 'issues' && can?(current_user, :admin_issue, @project) + = 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" @@ -12,26 +15,42 @@ - if params[:author_id].present? = hidden_field_tag(:author_id, params[:author_id]) = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit", - placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) + placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user.try(:username), current_user: true, project_id: @project.try(:id), selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) .filter-item.inline - if params[:assignee_id].present? = hidden_field_tag(:assignee_id, params[:assignee_id]) = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) .filter-item.inline.milestone-filter - = render "shared/issuable/milestone_dropdown" + = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown" + = render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } + + .filter-item.inline.reset-filters + %a{href: page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search])} Reset filters .pull-right - = render 'shared/sort_dropdown' + - if boards_page + #js-boards-seach.issue-boards-search + %input.pull-left.form-control{ type: "search", placeholder: "Filter by name...", "v-model" => "filters.search", "debounce" => "250" } + - if can?(current_user, :admin_list, @project) + .dropdown.pull-right + %button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } } + Create new list + .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable + = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" } + - if can?(current_user, :admin_label, @project) + = render partial: "shared/issuable/label_page_create" + = dropdown_loading + - else + = render 'shared/sort_dropdown' - - if controller.controller_name == 'issues' + - if @bulk_edit .issues_bulk_update.hide - = form_tag bulk_update_namespace_project_issues_path(@project.namespace, @project), method: :post, class: 'bulk-update' do + = 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 @@ -45,7 +64,7 @@ .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, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = 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 @@ -54,10 +73,10 @@ %li %a{href: "#", data: {id: "unsubscribe"}} Unsubscribe - = hidden_field_tag 'update[issues_ids]', [] + = hidden_field_tag 'update[issuable_ids]', [] = hidden_field_tag :state_event, params[:state_event] .filter-item.inline - = button_tag "Update issues", class: "btn update_selected_issues btn-save" + = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" - if !@labels.nil? .row-content-block.second-block.filtered-labels{ class: ("hidden" if !@labels.any?) } diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 78f38bbcde5..ac1984418d8 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -1,8 +1,33 @@ +- project = @target_project || @project = form_errors(issuable) +- if @conflict + .alert.alert-danger + Someone edited the #{issuable.class.model_name.human.downcase} the same time you did. + Please check out + = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank" + and make sure your changes will not unintentionally remove theirs + .form-group = f.label :title, class: 'control-label' - .col-sm-10 + + - issuable_template_names = issuable_templates(issuable) + + - if issuable_template_names.any? + .col-sm-3.col-lg-2 + .js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } } + - title = selected_template(issuable) || "Choose a template" + + = dropdown_tag(title, options: { toggle_class: 'js-issuable-selector', + title: title, filter: true, placeholder: 'Filter', footer_content: true, + data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: ref_project.path, namespace_path: ref_project.namespace.path } } ) do + %ul.dropdown-footer-list + %li + %a.no-template + No template + %a.reset-template + Reset template + %div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' } = f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off', class: 'form-control pad', required: true @@ -23,6 +48,13 @@ to prevent a %strong Work In Progress merge request from being merged before it's ready. + + - if can_add_template?(issuable) + %p.help-block + Add + = link_to "description templates", help_page_path('user/project/description_templates'), tabindex: -1 + to help your contributors communicate effectively! + .form-group.detail-page-description = f.label :description, 'Description', class: 'control-label' .col-sm-10 @@ -30,8 +62,9 @@ = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', - placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + placeholder: "Write a comment or drag your files here...", + supports_slash_commands: !issuable.persisted? + = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? .clearfix .error-alert @@ -52,38 +85,22 @@ = f.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } .issuable-form-select-holder - = users_select_tag("#{issuable.class.model_name.param_key}[assignee_id]", - placeholder: 'Select assignee', class: 'custom-form-control', null_user: true, - selected: issuable.assignee_id, project: @target_project || @project, - first_user: true, current_user: true, include_blank: true) - %div - = link_to 'Assign to me', '#', class: 'assign-to-me-link prepend-top-5 inline' + - if issuable.assignee_id + = f.hidden_field :assignee_id + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee", show_menu_above: true } }) .form-group.issue-milestone = f.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } - - if milestone_options(issuable).present? - .issuable-form-select-holder - = f.select(:milestone_id, milestone_options(issuable), - { include_blank: true }, { class: 'select2', data: { placeholder: 'Select milestone' } }) - - else - .prepend-top-10 - %span.light No open milestones available. - - if can? current_user, :admin_milestone, issuable.project - %div - = link_to 'Create new milestone', new_namespace_project_milestone_path(issuable.project.namespace, issuable.project), target: :blank, class: "prepend-top-5 inline" + .issuable-form-select-holder + = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_menu_above: true, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" .form-group - has_labels = issuable.project.labels.any? = f.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}" + = f.hidden_field :label_ids, multiple: true, value: '' .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } - - if has_labels - .issuable-form-select-holder - = f.collection_select :label_ids, issuable.project.labels.all, :id, :name, - { selected: issuable.label_ids }, multiple: true, class: 'select2', data: { placeholder: "Select labels" } - - else - %span.light No labels yet. - - if can? current_user, :admin_label, issuable.project - %div - = link_to 'Create new label', new_namespace_project_label_path(issuable.project.namespace, issuable.project), target: :blank, class: "prepend-top-5 inline" + .issuable-form-select-holder + = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false, show_menu_above: 'true' }, dropdown_title: "Select label" - if has_due_date .col-lg-6 .form-group @@ -98,13 +115,13 @@ = label_tag :move_to_project_id, 'Move', class: 'control-label' .col-sm-10 .issuable-form-select-holder - = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id) } + = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE } %span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default', title: 'Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.' } = icon('question-circle') -- if issuable.is_a?(MergeRequest) +- if issuable.is_a?(MergeRequest) && !issuable.closed_without_fork? %hr - if @merge_request.new_record? .form-group @@ -145,7 +162,9 @@ = link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]), class: 'btn btn-cancel' - else .pull-right - - if current_user.can?(:"destroy_#{issuable.to_ability_name}", @project) + - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project) = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.class.name.titleize} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped' = link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel' + += f.hidden_field :lock_version diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index d34d28f6736..22b5a6aa11b 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -1,25 +1,30 @@ +- project = @target_project || @project - show_create = local_assigns.fetch(:show_create, true) - extra_options = local_assigns.fetch(:extra_options, true) - filter_submit = local_assigns.fetch(:filter_submit, true) - show_footer = local_assigns.fetch(:show_footer, true) +- use_id = local_assigns.fetch(:use_id, true) - data_options = local_assigns.fetch(:data_options, {}) - classes = local_assigns.fetch(:classes, []) -- dropdown_data = {toggle: 'dropdown', field_name: 'label_name[]', show_no: "true", show_any: "true", selected: params[:label_name], project_id: @project.try(:id), labels: labels_filter_path, default_label: "Label"} +- selected = local_assigns.fetch(:selected, nil) +- selected_toggle = local_assigns.fetch(:selected_toggle, nil) +- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") +- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"} - dropdown_data.merge!(data_options) - classes << 'js-extra-options' if extra_options - classes << 'js-filter-submit' if filter_submit -- if params[:label_name].present? - - if params[:label_name].respond_to?('any?') - - params[:label_name].each do |label| - = hidden_field_tag "label_name[]", label, id: nil +- if selected + - selected.each do |label| + = hidden_field_tag data_options[:field_name], use_id ? label.try(:id) : label.try(:title), id: nil + .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect{class: classes.join(' '), type: "button", data: dropdown_data} - %span.dropdown-toggle-text - = h(multi_label_name(params[:label_name], "Label")) + %span.dropdown-toggle-text{ class: ("is-default" if selected.nil? || selected.empty?) } + = multi_label_name(selected, "Labels") = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable - = render partial: "shared/issuable/label_page_default", locals: { title: "Filter by label", show_footer: show_footer, show_create: show_create } - - if show_create and @project and can?(current_user, :admin_label, @project) + = render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create } + - if show_create && project && can?(current_user, :admin_label, project) = render partial: "shared/issuable/label_page_create" = dropdown_loading diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index 4e280c371ac..c0dc63be2bf 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -2,8 +2,16 @@ - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels') +- show_boards_content = local_assigns.fetch(:show_boards_content, false) .dropdown-page-one = dropdown_title(title) + - if show_boards_content + .issue-board-dropdown-content + %p + Each label that exists in your issue tracker can have its own dedicated + list. Select a label below to add a list to your Board and it will + automatically be populated with issues that have that label. To create + a list for a label that doesn't exist yet, simply create the label below. = dropdown_filter(filter_placeholder) = dropdown_content - if @project && show_footer @@ -12,7 +20,7 @@ - if can?(current_user, :admin_label, @project) %li %a.dropdown-toggle-page{href: "#"} - Create new + Create new label %li = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do - if show_create && @project && can?(current_user, :admin_label, @project) diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index 2fcf40ece99..f27a9002ec2 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -1,16 +1,21 @@ -- if params[:milestone_title].present? - = hidden_field_tag(:milestone_title, params[:milestone_title]) -= dropdown_tag(milestone_dropdown_label(params[:milestone_title]), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", - placeholder: "Search milestones", footer_content: @project.present?, data: { show_no: true, show_any: true, show_upcoming: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: @project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do - - if @project +- project = @target_project || @project +- extra_class = extra_class || '' +- show_menu_above = show_menu_above || false +- selected_text = selected.try(:title) +- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone") +- if selected.present? + = hidden_field_tag(name, name == :milestone_title ? selected.title : selected.id) += dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", + placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do + - if project %ul.dropdown-footer-list - - if can? current_user, :admin_milestone, @project + - if can? current_user, :admin_milestone, project %li - = link_to new_namespace_project_milestone_path(@project.namespace, @project), title: "New Milestone" do + = link_to new_namespace_project_milestone_path(project.namespace, project), title: "New Milestone" do Create new %li - = link_to namespace_project_milestones_path(@project.namespace, @project) do - - if can? current_user, :admin_milestone, @project + = link_to namespace_project_milestones_path(project.namespace, project) do + - if can? current_user, :admin_milestone, project Manage milestones - else View milestones diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index 1d9b09a5ef1..5527a2f889a 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -1,25 +1,25 @@ +- type = local_assigns.fetch(:type, :issues) +- page_context_word = type.to_s.humanize(capitalize: false) +- issuables = @issues || @merge_requests + %ul.nav-links.issues-state-filters - - if defined?(type) && type == :merge_requests - - page_context_word = 'merge requests' - - else - - page_context_word = 'issues' %li{class: ("active" if params[:state] == 'opened')} = link_to page_filter_path(state: 'opened', label: true), title: "Filter by #{page_context_word} that are currently opened." do - #{state_filters_text_for(:opened, @project)} + #{issuables_state_counter_text(type, :opened)} - - if defined?(type) && type == :merge_requests + - if type == :merge_requests %li{class: ("active" if params[:state] == 'merged')} = link_to page_filter_path(state: 'merged', label: true), title: 'Filter by merge requests that are currently merged.' do - #{state_filters_text_for(:merged, @project)} + #{issuables_state_counter_text(type, :merged)} %li{class: ("active" if params[:state] == 'closed')} = link_to page_filter_path(state: 'closed', label: true), title: 'Filter by merge requests that are currently closed and unmerged.' do - #{state_filters_text_for(:closed, @project)} + #{issuables_state_counter_text(type, :closed)} - else %li{class: ("active" if params[:state] == 'closed')} = link_to page_filter_path(state: 'closed', label: true), title: 'Filter by issues that are currently closed.' do - #{state_filters_text_for(:closed, @project)} + #{issuables_state_counter_text(type, :closed)} %li{class: ("active" if params[:state] == 'all')} = link_to page_filter_path(state: 'all', label: true), title: "Show all #{page_context_word}." do - #{state_filters_text_for(:all, @project)} + #{issuables_state_counter_text(type, :all)} diff --git a/app/views/shared/issuable/_search_form.html.haml b/app/views/shared/issuable/_search_form.html.haml index 186963b32b8..2c89217cadd 100644 --- a/app/views/shared/issuable/_search_form.html.haml +++ b/app/views/shared/issuable/_search_form.html.haml @@ -1,2 +1,2 @@ -= form_tag(path, method: :get, id: "issue_search_form", class: 'issue-search-form') do - = search_field_tag :issue_search, params[:issue_search], { placeholder: 'Filter by name ...', class: 'form-control issue_search search-text-input input-short', spellcheck: false } += form_tag(path, method: :get, id: "issuable_search_form", class: 'issuable-search-form') do + = search_field_tag :search, params[:search], { id: 'issuable_search', placeholder: 'Filter by name ...', class: 'form-control issuable_search search-text-input input-short', spellcheck: false } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 8e2fcbdfab8..f8059988038 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -108,29 +108,30 @@ .js-due-date-calendar - if issuable.project.labels.any? + - selected_labels = issuable.labels .block.labels - .sidebar-collapsed-icon + .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } } = icon('tags') %span - = issuable.labels_array.size + = selected_labels.size .title.hide-collapsed Labels = icon('spinner spin', class: 'block-loading') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.issuable-show-labels.hide-collapsed{ class: ("has-labels" if issuable.labels_array.any?) } - - if issuable.labels_array.any? - - issuable.labels_array.each do |label| + .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } + - if selected_labels.any? + - selected_labels.each do |label| = link_to_label(label, type: issuable.to_ability_name) - else %span.no-value None .selectbox.hide-collapsed - - issuable.labels_array.each do |label| + - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect{type: "button", data: {toggle: "dropdown", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", project_id: (@project.id if @project), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project)}} - %span.dropdown-toggle-text - Label + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project)}} + %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?)} + = multi_label_name(selected_labels, "Labels") = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable = render partial: "shared/issuable/label_page_default" diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 5ae485f36ba..5f20e4bd42a 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -1,4 +1,4 @@ -- show_roles = local_assigns.fetch(:show_roles, default_show_roles(member)) +- show_roles = local_assigns.fetch(:show_roles, true) - show_controls = local_assigns.fetch(:show_controls, true) - user = member.user @@ -16,7 +16,7 @@ = button_tag icon('pencil'), type: 'button', class: 'btn inline js-toggle-button', - title: 'Edit access level' + title: 'Edit' - if member.request? = link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]), @@ -59,6 +59,10 @@ = time_ago_with_tooltip(member.requested_at) - else Joined #{time_ago_with_tooltip(member.created_at)} + - if member.expires? + · + %span{ class: ('text-warning' if member.expires_soon?) } + Expires in #{distance_of_time_in_words_to_now(member.expires_at)} - else = image_tag avatar_icon(member.invite_email, 40), class: "avatar s40", alt: '' @@ -73,8 +77,16 @@ - if show_roles .edit-member.hide.js-toggle-content %br - = form_for member, remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control' + = form_for member, remote: true, html: { class: 'form-horizontal' } do |f| + .form-group + = label_tag "member_access_level_#{member.id}", 'Project access', class: 'control-label' + .col-sm-10 + = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control', id: "member_access_level_#{member.id}" + .form-group + = label_tag "member_expires_at_#{member.id}", 'Access expiration date', class: 'control-label' + .col-sm-10 + .clearable-input + = f.text_field :expires_at, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date', id: "member_expires_at_#{member.id}" + %i.clear-icon.js-clear-input .prepend-top-10 = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index b15e8ea73fe..33f93dccd3c 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -8,7 +8,7 @@ = link_to milestones_label_path(options) do - render_colored_label(label, tooltip: false) %span.prepend-description-left - = markdown(label.description, pipeline: :single_line) + = markdown_field(label, :description) .pull-info-right %span.append-right-20 diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index acc3ccf4dcf..3dccfb147bf 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -33,7 +33,7 @@ - if @project .row .col-sm-6= render('shared/milestone_expired', milestone: milestone) - .col-sm-6 + .col-sm-6.milestone-actions - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? = link_to edit_namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), class: "btn btn-xs btn-grouped" do Edit diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 7ff947a51db..548215243db 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -26,7 +26,7 @@ .detail-page-description.milestone-detail %h2.title - = markdown escape_once(milestone.title), pipeline: :single_line + = markdown_field(milestone, :title) - if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default @@ -55,4 +55,3 @@ Open %td = ms.expires_at - diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index ff1cf966a9b..feaa5570c21 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -11,7 +11,7 @@ = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } - %span.caret + = icon('caret-down') .sr-only Toggle dropdown - else %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index b8b66d08db8..e8668048703 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,19 +12,21 @@ %li.project-row{ class: css_class } = cache(cache_key) do .controls + - if project.archived + %span.label.label-warning archived - if project.commit.try(:status) %span = render_commit_status(project.commit) - if forks %span = icon('code-fork') - = project.forks_count + = number_with_delimiter(project.forks_count) - if stars %span = icon('star') - = project.star_count + = number_with_delimiter(project.star_count) %span.visibility-icon.has-tooltip{data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project)} - = visibility_level_icon(project.visibility_level, fw: false) + = visibility_level_icon(project.visibility_level, fw: true) .title = link_to project_path(project), class: dom_class(project) do @@ -48,4 +50,4 @@ class: "commit-row-message" - elsif project.description.present? .description - = markdown(project.description, pipeline: :description) + = markdown_field(project, :description) diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 773ce8ac240..dcdba01aee9 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,9 +1,12 @@ - unless @snippet.content.empty? - if markup?(@snippet.file_name) %textarea.markdown-snippet-copy.blob-content{data: {blob_id: @snippet.id}} - = @snippet.data + = @snippet.content .file-content.wiki - = render_markup(@snippet.file_name, @snippet.data) + - if gitlab_markdown?(@snippet.file_name) + = preserve(markdown_field(@snippet, :content)) + - else + = render_markup(@snippet.file_name, @snippet.content) - else = render 'shared/file_highlight', blob: @snippet - else diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 47ec09f62c6..0c788032020 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -1,3 +1,7 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') + = page_specific_javascript_tag('snippet/snippet_bundle.js') + .snippet-form-holder = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input" } do |f| = form_errors(@snippet) @@ -31,8 +35,3 @@ - else = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel" -:javascript - var editor = ace.edit("editor"); - $(".snippet-form-holder form").submit(function(){ - $(".snippet-file-content").val(editor.getValue()); - }); diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index af753496260..d7506e07ff6 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -6,12 +6,13 @@ %strong.item-title Snippet #{@snippet.to_reference} %span.creator - created by #{link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title")} + authored = time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago') - if @snippet.updated_at != @snippet.created_at %span = icon('edit', title: 'edited') = time_ago_with_tooltip(@snippet.updated_at, placement: 'bottom', html_class: 'snippet_edited_ago') + by #{link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title", avatar_class: "hidden-xs")} .snippet-actions - if @snippet.project_id? @@ -19,6 +20,5 @@ - else = render "snippets/actions" -.content-block.second-block - %h2.snippet-title.prepend-top-0.append-bottom-0 - = markdown escape_once(@snippet.title), pipeline: :single_line, author: @snippet.author +%h2.snippet-title.prepend-top-0.append-bottom-0 + = markdown_field(@snippet, :title) diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index c96dfefe17f..ea17bec8677 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -3,19 +3,30 @@ .title = link_to reliable_snippet_path(snippet) do - = truncate(snippet.title, length: 60) + = snippet.title - if snippet.private? - %span.label.label-gray + %span.label.label-gray.hidden-xs = icon('lock') private - %span.monospace.pull-right + %span.monospace.pull-right.hidden-xs = snippet.file_name - %small.pull-right.cgray + %ul.controls.visible-xs + %li + - note_count = snippet.notes.user.count + = link_to reliable_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do + = icon('comments') + = note_count + %li + %span.sr-only + = visibility_level_label(snippet.visibility_level) + = visibility_level_icon(snippet.visibility_level, fw: false) + + %small.pull-right.cgray.hidden-xs - if snippet.project_id? = link_to snippet.project.name_with_namespace, namespace_project_path(snippet.project.namespace, snippet.project) - .snippet-info + .snippet-info.hidden-xs = link_to user_snippets_path(snippet.author) do = snippet.author_name authored #{time_ago_with_tooltip(snippet.created_at)} diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 2585ed9360b..5d659eb83a9 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -19,7 +19,7 @@ = f.label :token, "Secret Token", class: 'label-light' = f.text_field :token, class: "form-control", placeholder: '' %p.help-block - Use this token to validate received payloads + Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. .form-group = f.label :url, "Trigger", class: 'label-light' %ul.list-unstyled @@ -29,49 +29,63 @@ = f.label :push_events, class: 'list-label' do %strong Push events %p.light - This url will be triggered by a push to the repository + This URL will be triggered by a push to the repository %li = f.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = f.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light - This url will be triggered when a new tag is pushed to the repository + This URL will be triggered when a new tag is pushed to the repository %li = f.check_box :note_events, class: 'pull-left' .prepend-left-20 = f.label :note_events, class: 'list-label' do %strong Comments %p.light - This url will be triggered when someone adds a comment + This URL will be triggered when someone adds a comment %li = f.check_box :issues_events, class: 'pull-left' .prepend-left-20 = f.label :issues_events, class: 'list-label' do %strong Issues events %p.light - This url will be triggered when an issue is created/updated/merged + This URL will be triggered when an issue is created/updated/merged + %li + = f.check_box :confidential_issues_events, class: 'pull-left' + .prepend-left-20 + = f.label :confidential_issues_events, class: 'list-label' do + %strong Confidential Issues events + %p.light + This URL will be triggered when a confidential issue is created/updated/merged %li = f.check_box :merge_requests_events, class: 'pull-left' .prepend-left-20 = f.label :merge_requests_events, class: 'list-label' do %strong Merge Request events %p.light - This url will be triggered when a merge request is created/updated/merged + This URL will be triggered when a merge request is created/updated/merged %li = f.check_box :build_events, class: 'pull-left' .prepend-left-20 = f.label :build_events, class: 'list-label' do %strong Build events %p.light - This url will be triggered when the build status changes + This URL will be triggered when the build status changes + %li + = f.check_box :pipeline_events, class: 'pull-left' + .prepend-left-20 + = f.label :pipeline_events, class: 'list-label' do + %strong Pipeline events + %p.light + This URL will be triggered when the pipeline status changes %li = f.check_box :wiki_page_events, class: 'pull-left' .prepend-left-20 = f.label :wiki_page_events, class: 'list-label' do %strong Wiki Page events %p.light - This url will be triggered when a wiki page is created/updated + This URL will be triggered when a wiki page is created/updated .form-group = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox' .checkbox diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 160c6cd84da..1d0e549ed3d 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -1,28 +1,28 @@ .hidden-xs - if current_user - = link_to new_snippet_path, class: "btn btn-grouped btn-create new-snippet-link", title: "New Snippet" do - New Snippet - - if can?(current_user, :update_personal_snippet, @snippet) - = link_to edit_snippet_path(@snippet), class: "btn btn-grouped snippable-edit" do - Edit + = link_to new_snippet_path, class: "btn btn-grouped btn-create new-snippet-link", title: "New snippet" do + New snippet - if can?(current_user, :admin_personal_snippet, @snippet) = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-danger", title: 'Delete Snippet' do Delete + - if can?(current_user, :update_personal_snippet, @snippet) + = link_to edit_snippet_path(@snippet), class: "btn btn-grouped snippable-edit" do + Edit - if current_user .visible-xs-block.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } Options - %span.caret + = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul %li - = link_to new_snippet_path, title: "New Snippet" do - New Snippet - - if can?(current_user, :update_personal_snippet, @snippet) - %li - = link_to edit_snippet_path(@snippet) do - Edit + = link_to new_snippet_path, title: "New snippet" do + New snippet - if can?(current_user, :admin_personal_snippet, @snippet) %li = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do Delete + - if can?(current_user, :update_personal_snippet, @snippet) + %li + = link_to edit_snippet_path(@snippet) do + Edit diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index 80a3e731e1d..77b66ca74b6 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -1,7 +1,13 @@ -%ul.content-list - = render partial: 'shared/snippets/snippet', collection: @snippets - - if @snippets.empty? - %li - .nothing-here-block Nothing here. +- remote = local_assigns.fetch(:remote, false) -= paginate @snippets, theme: 'gitlab' +.snippets-list-holder + %ul.content-list + = render partial: 'shared/snippets/snippet', collection: @snippets + - if @snippets.empty? + %li + .nothing-here-block Nothing here. + + = paginate @snippets, theme: 'gitlab', remote: remote + +:javascript + gl.SnippetsList(); diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index ed3992650d4..27d7a6c5bb6 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -1,13 +1,15 @@ - page_title @snippet.title, "Snippets" -.snippet-holder - = render 'shared/snippets/header' += render 'shared/snippets/header' - %article.file-holder.file-holder-no-border.snippet-file-content - .file-title.file-title-clear - = blob_icon 0, @snippet.file_name - = @snippet.file_name - .file-actions.hidden-xs - = clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{@snippet.id}']") - = link_to 'Raw', raw_snippet_path(@snippet), class: "btn btn-sm", target: "_blank" - = render 'shared/snippets/blob' +%article.file-holder.snippet-file-content + .file-title + = blob_icon 0, @snippet.file_name + = @snippet.file_name + .file-actions + = clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{@snippet.id}']") + = link_to 'Raw', raw_snippet_path(@snippet), class: "btn btn-sm", target: "_blank" + = link_to 'Download', download_snippet_path(@snippet), class: "btn btn-sm" + = render 'shared/snippets/blob' + += render 'award_emoji/awards_block', awardable: @snippet, inline: true
\ No newline at end of file diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml index 75fb0e303ad..9657101ace5 100644 --- a/app/views/u2f/_authenticate.html.haml +++ b/app/views/u2f/_authenticate.html.haml @@ -20,6 +20,8 @@ %div %p We heard back from your U2F device. Click this button to authenticate with the GitLab server. = form_tag(new_user_session_path, method: :post) do |f| + - resource_params = params[resource_name].presence || params + = hidden_field_tag 'user[remember_me]', resource_params.fetch(:remember_me, 0) = hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" = submit_tag "Authenticate via U2F Device", class: "btn btn-success" diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index cbb8dfb7829..8f7b42eb351 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -28,10 +28,15 @@ %script#js-register-u2f-registered{ type: "text/template" } %div.row.append-bottom-10 - %p Your device was successfully set up! Click this button to register with the GitLab server. - = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do - = hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response" - = submit_tag "Register U2F Device", class: "btn btn-success" + .col-md-12 + %p Your device was successfully set up! Give it a name and register it with the GitLab server. + = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do + .row.append-bottom-10 + .col-md-3 + = text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name" + .col-md-3 + = hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" + = submit_tag "Register U2F Device", class: "btn btn-success" :javascript var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f); diff --git a/app/views/users/calendar.html.haml b/app/views/users/calendar.html.haml index 77f2ddefb1e..09ff8a76d27 100644 --- a/app/views/users/calendar.html.haml +++ b/app/views/users/calendar.html.haml @@ -4,6 +4,6 @@ Summary of issues, merge requests, and push events :javascript new Calendar( - #{@timestamps.to_json}, + #{@activity_dates.to_json}, '#{user_calendar_activities_path}' - ); + );
\ No newline at end of file diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index c7f39868e71..1e0752bd3c3 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -10,75 +10,79 @@ = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") .user-profile - .cover-block + .cover-block.user-cover-block .cover-controls - if @user == current_user = link_to profile_path, class: 'btn btn-gray' do = icon('pencil') - elsif current_user - %span.report-abuse - - if @user.abuse_report - %button.btn.btn-danger{ title: 'Already reported for abuse', - data: { toggle: 'tooltip', placement: 'left', container: 'body' }} - = icon('exclamation-circle') - - else - = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn btn-gray', - title: 'Report abuse', data: {toggle: 'tooltip', placement: 'left', container: 'body'} do - = icon('exclamation-circle') + - if @user.abuse_report + %button.btn.btn-danger{ title: 'Already reported for abuse', + data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }} + = icon('exclamation-circle') + - else + = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: 'btn btn-gray', + title: 'Report abuse', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = icon('exclamation-circle') - if current_user - = link_to user_path(@user, :atom, { private_token: current_user.private_token }), class: 'btn btn-gray' do = icon('rss') - if current_user.admin? - = link_to [:admin, @user], class: 'btn btn-gray', title: 'View user in admin area', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('users') - .avatar-holder - = link_to avatar_icon(@user, 400), target: '_blank' do - = image_tag avatar_icon(@user, 90), class: "avatar s90", alt: '' - .cover-title - = @user.name - - .cover-desc - %span.middle-dot-divider - @#{@user.username} - %span.middle-dot-divider - Member since #{@user.created_at.to_s(:medium)} + .profile-header + .avatar-holder + = link_to avatar_icon(@user, 400), target: '_blank' do + = image_tag avatar_icon(@user, 90), class: "avatar s90", alt: '' + + .user-info + .cover-title + = @user.name + + .cover-desc.member-date + %span.middle-dot-divider + @#{@user.username} + %span.middle-dot-divider + Member since #{@user.created_at.to_s(:medium)} + + .cover-desc + - unless @user.public_email.blank? + .profile-link-holder.middle-dot-divider + = link_to @user.public_email, "mailto:#{@user.public_email}" + - unless @user.skype.blank? + .profile-link-holder.middle-dot-divider + = link_to "skype:#{@user.skype}", title: "Skype" do + = icon('skype') + - unless @user.linkedin.blank? + .profile-link-holder.middle-dot-divider + = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do + = icon('linkedin-square') + - unless @user.twitter.blank? + .profile-link-holder.middle-dot-divider + = link_to "https://twitter.com/#{@user.twitter}", title: "Twitter" do + = icon('twitter-square') + - unless @user.website_url.blank? + .profile-link-holder.middle-dot-divider + = link_to @user.short_website_url, @user.full_website_url + - unless @user.location.blank? + .profile-link-holder.middle-dot-divider + = icon('map-marker') + = @user.location + - unless @user.organization.blank? + .profile-link-holder.middle-dot-divider + = icon('building') + = @user.organization - if @user.bio.present? .cover-desc %p.profile-user-bio = @user.bio - .cover-desc - - unless @user.public_email.blank? - .profile-link-holder.middle-dot-divider - = link_to @user.public_email, "mailto:#{@user.public_email}" - - unless @user.skype.blank? - .profile-link-holder.middle-dot-divider - = link_to "skype:#{@user.skype}", title: "Skype" do - = icon('skype') - - unless @user.linkedin.blank? - .profile-link-holder.middle-dot-divider - = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do - = icon('linkedin-square') - - unless @user.twitter.blank? - .profile-link-holder.middle-dot-divider - = link_to "https://twitter.com/#{@user.twitter}", title: "Twitter" do - = icon('twitter-square') - - unless @user.website_url.blank? - .profile-link-holder.middle-dot-divider - = link_to @user.short_website_url, @user.full_website_url - - unless @user.location.blank? - .profile-link-holder.middle-dot-divider - = icon('map-marker') - = @user.location - %ul.nav-links.center.user-profile-nav %li.js-activity-tab - = link_to user_calendar_activities_path, data: {target: 'div#activity', action: 'activity', toggle: 'tab'} do + = link_to user_path, data: {target: 'div#activity', action: 'activity', toggle: 'tab'} do Activity %li.js-groups-tab = link_to user_groups_path, data: {target: 'div#groups', action: 'groups', toggle: 'tab'} do @@ -123,6 +127,6 @@ :javascript var userProfile; - userProfile = new User({ + userProfile = new gl.User({ action: "#{controller.action_name}" }); diff --git a/app/workers/clear_database_cache_worker.rb b/app/workers/clear_database_cache_worker.rb new file mode 100644 index 00000000000..c541daba50e --- /dev/null +++ b/app/workers/clear_database_cache_worker.rb @@ -0,0 +1,23 @@ +# This worker clears all cache fields in the database, working in batches. +class ClearDatabaseCacheWorker + include Sidekiq::Worker + + BATCH_SIZE = 1000 + + def perform + CacheMarkdownField.caching_classes.each do |kls| + fields = kls.cached_markdown_fields.html_fields + clear_cache_fields = fields.each_with_object({}) do |field, memo| + memo[field] = nil + end + + Rails.logger.debug("Clearing Markdown cache for #{kls}: #{fields.inspect}") + + kls.unscoped.in_batches(of: BATCH_SIZE) do |relation| + relation.update_all(clear_cache_fields) + end + end + + nil + end +end diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index c6a5af2809a..1dc7e0adef7 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -33,13 +33,13 @@ class EmailsOnPushWorker reverse_compare = false if action == :push - compare = CompareService.new.execute(project, before_sha, project, after_sha) + compare = CompareService.new.execute(project, after_sha, project, before_sha) diff_refs = compare.diff_refs return false if compare.same if compare.commits.empty? - compare = CompareService.new.execute(project, after_sha, project, before_sha) + compare = CompareService.new.execute(project, before_sha, project, after_sha) diff_refs = compare.diff_refs reverse_compare = true diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index c64ea108d52..174eabff9fd 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -2,12 +2,11 @@ class ExpireBuildArtifactsWorker include Sidekiq::Worker def perform - Rails.logger.info 'Cleaning old build artifacts' + Rails.logger.info 'Scheduling removal of build artifacts' - builds = Ci::Build.with_expired_artifacts - builds.find_each(batch_size: 50).each do |build| - Rails.logger.debug "Removing artifacts build #{build.id}..." - build.erase_artifacts! - end + build_ids = Ci::Build.with_expired_artifacts.pluck(:id) + build_ids = build_ids.map { |build_id| [build_id] } + + Sidekiq::Client.push_bulk('class' => ExpireBuildInstanceArtifactsWorker, 'args' => build_ids ) end end diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb new file mode 100644 index 00000000000..916c2e633c1 --- /dev/null +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -0,0 +1,11 @@ +class ExpireBuildInstanceArtifactsWorker + include Sidekiq::Worker + + def perform(build_id) + build = Ci::Build.with_expired_artifacts.reorder(nil).find_by(id: build_id) + return unless build + + Rails.logger.info "Removing artifacts build #{build.id}..." + build.erase_artifacts! + end +end diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb new file mode 100644 index 00000000000..5048746f09b --- /dev/null +++ b/app/workers/group_destroy_worker.rb @@ -0,0 +1,17 @@ +class GroupDestroyWorker + include Sidekiq::Worker + + sidekiq_options queue: :default + + def perform(group_id, user_id) + begin + group = Group.with_deleted.find(group_id) + rescue ActiveRecord::RecordNotFound + return + end + + user = User.find(user_id) + + DestroyGroupService.new(group, user).execute + end +end diff --git a/app/workers/gitlab_remove_project_export_worker.rb b/app/workers/import_export_project_cleanup_worker.rb index 1d91897d520..72e3a9ae734 100644 --- a/app/workers/gitlab_remove_project_export_worker.rb +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -1,9 +1,9 @@ -class GitlabRemoveProjectExportWorker +class ImportExportProjectCleanupWorker include Sidekiq::Worker sidekiq_options queue: :default def perform - Project.remove_gitlab_exports! + ImportExportCleanUpService.new.execute end end diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb new file mode 100644 index 00000000000..f44227d7086 --- /dev/null +++ b/app/workers/pipeline_process_worker.rb @@ -0,0 +1,10 @@ +class PipelineProcessWorker + include Sidekiq::Worker + + sidekiq_options queue: :default + + def perform(pipeline_id) + Ci::Pipeline.find_by(id: pipeline_id) + .try(:process!) + end +end diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb new file mode 100644 index 00000000000..5dd443fea59 --- /dev/null +++ b/app/workers/pipeline_success_worker.rb @@ -0,0 +1,12 @@ +class PipelineSuccessWorker + include Sidekiq::Worker + sidekiq_options queue: :default + + def perform(pipeline_id) + Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| + MergeRequests::MergeWhenBuildSucceedsService + .new(pipeline.project, nil) + .trigger(pipeline) + end + end +end diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb new file mode 100644 index 00000000000..44a7f24e401 --- /dev/null +++ b/app/workers/pipeline_update_worker.rb @@ -0,0 +1,10 @@ +class PipelineUpdateWorker + include Sidekiq::Worker + + sidekiq_options queue: :default + + def perform(pipeline_id) + Ci::Pipeline.find_by(id: pipeline_id) + .try(:update_status) + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 09035a7cf2d..a9a2b716005 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -10,6 +10,10 @@ class PostReceive log("Check gitlab.yml config for correct repositories.storages values. No repository storage path matches \"#{repo_path}\"") end + changes = Base64.decode64(changes) unless changes.include?(' ') + # Use Sidekiq.logger so arguments can be correlated with execution + # time and thread ID's. + Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] post_received = Gitlab::GitPostReceive.new(repo_path, identifier, changes) if post_received.project.nil? diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb new file mode 100644 index 00000000000..5883cafe1d1 --- /dev/null +++ b/app/workers/prune_old_events_worker.rb @@ -0,0 +1,17 @@ +class PruneOldEventsWorker + include Sidekiq::Worker + + def perform + # Contribution calendar shows maximum 12 months of events. + # Double nested query is used because MySQL doesn't allow DELETE subqueries + # on the same table. + Event.unscoped.where( + '(id IN (SELECT id FROM (?) ids_to_remove))', + Event.unscoped.where( + 'created_at < ?', + (12.months + 1.day).ago). + select(:id). + limit(10_000)). + delete_all + end +end diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb new file mode 100644 index 00000000000..246c8b6650a --- /dev/null +++ b/app/workers/remove_expired_group_links_worker.rb @@ -0,0 +1,7 @@ +class RemoveExpiredGroupLinksWorker + include Sidekiq::Worker + + def perform + ProjectGroupLink.expired.destroy_all + end +end diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb new file mode 100644 index 00000000000..cf765af97ce --- /dev/null +++ b/app/workers/remove_expired_members_worker.rb @@ -0,0 +1,13 @@ +class RemoveExpiredMembersWorker + include Sidekiq::Worker + + def perform + Member.expired.find_each do |member| + begin + Members::AuthorizedDestroyService.new(member).execute + rescue => ex + logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}") + end + end + end +end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index d69d6037053..61ed1c38ac4 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -5,6 +5,10 @@ class RepositoryForkWorker sidekiq_options queue: :gitlab_shell def perform(project_id, forked_from_repository_storage_path, source_path, target_path) + Gitlab::Metrics.add_event(:fork_repository, + source_path: source_path, + target_path: target_path) + project = Project.find_by_id(project_id) unless project.present? diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 7d819fe78f8..d2ca8813ab9 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -10,6 +10,12 @@ class RepositoryImportWorker @project = Project.find(project_id) @current_user = @project.creator + Gitlab::Metrics.add_event(:import_repository, + import_url: @project.import_url, + path: @project.path_with_namespace) + + project.update_column(:import_error, nil) + result = Projects::ImportService.new(project, current_user).execute if result[:status] == :error diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb new file mode 100644 index 00000000000..df4c4a6628b --- /dev/null +++ b/app/workers/trending_projects_worker.rb @@ -0,0 +1,11 @@ +class TrendingProjectsWorker + include Sidekiq::Worker + + sidekiq_options queue: :trending_projects + + def perform + Rails.logger.info('Refreshing trending projects') + + TrendingProject.refresh! + end +end diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb new file mode 100644 index 00000000000..03f0528cdae --- /dev/null +++ b/app/workers/update_merge_requests_worker.rb @@ -0,0 +1,16 @@ +class UpdateMergeRequestsWorker + include Sidekiq::Worker + + def perform(project_id, user_id, oldrev, newrev, ref) + project = Project.find_by(id: project_id) + return unless project + + user = User.find_by(id: user_id) + return unless user + + MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref) + + push_data = Gitlab::DataBuilder::Push.build(project, user, oldrev, newrev, ref, []) + SystemHooksService.new.execute_hooks(push_data, :push_hooks) + end +end diff --git a/changelogs/archive.md b/changelogs/archive.md new file mode 100644 index 00000000000..c68ab694d39 --- /dev/null +++ b/changelogs/archive.md @@ -0,0 +1,1810 @@ +## 7.14.3 + +- No changes + +## 7.14.2 + +- Upgrade gitlab_git to 7.2.15 to fix `git blame` errors with ISO-encoded files (Stan Hu) +- Allow configuration of LDAP attributes GitLab will use for the new user account. + +## 7.14.1 + +- Improve abuse reports management from admin area +- Fix "Reload with full diff" URL button in compare branch view (Stan Hu) +- Disabled DNS lookups for SSH in docker image (Rowan Wookey) +- Only include base URL in OmniAuth full_host parameter (Stan Hu) +- Fix Error 500 in API when accessing a group that has an avatar (Stan Hu) +- Ability to enable SSL verification for Webhooks + +## 7.14.0 + +- Fix bug where non-project members of the target project could set labels on new merge requests. +- Update default robots.txt rules to disallow crawling of irrelevant pages (Ben Bodenmiller) +- Fix redirection after sign in when using auto_sign_in_with_provider +- Upgrade gitlab_git to 7.2.14 to ignore CRLFs in .gitmodules (Stan Hu) +- Clear cache to prevent listing deleted branches after MR removes source branch (Stan Hu) +- Provide more feedback what went wrong if HipChat service failed test (Stan Hu) +- Fix bug where backslashes in inline diffs could be dropped (Stan Hu) +- Disable turbolinks when linking to Bitbucket import status (Stan Hu) +- Fix broken code import and display error messages if something went wrong with creating project (Stan Hu) +- Fix corrupted binary files when using API files endpoint (Stan Hu) +- Bump Haml to 4.0.7 to speed up textarea rendering (Stan Hu) +- Show incompatible projects in Bitbucket import status (Stan Hu) +- Fix coloring of diffs on MR Discussion-tab (Gert Goet) +- Fix "Network" and "Graphs" pages for branches with encoded slashes (Stan Hu) +- Fix errors deleting and creating branches with encoded slashes (Stan Hu) +- Always add current user to autocomplete controller to support filter by "Me" (Stan Hu) +- Fix multi-line syntax highlighting (Stan Hu) +- Fix network graph when branch name has single quotes (Stan Hu) +- Add "Confirm user" button in user admin page (Stan Hu) +- Upgrade gitlab_git to version 7.2.6 to fix Error 500 when creating network graphs (Stan Hu) +- Add support for Unicode filenames in relative links (Hiroyuki Sato) +- Fix URL used for refreshing notes if relative_url is present (Bartłomiej Święcki) +- Fix commit data retrieval when branch name has single quotes (Stan Hu) +- Check that project was actually created rather than just validated in import:repos task (Stan Hu) +- Fix full screen mode for snippet comments (Daniel Gerhardt) +- Fix 404 error in files view after deleting the last file in a repository (Stan Hu) +- Fix the "Reload with full diff" URL button (Stan Hu) +- Fix label read access for unauthenticated users (Daniel Gerhardt) +- Fix access to disabled features for unauthenticated users (Daniel Gerhardt) +- Fix OAuth provider bug where GitLab would not go return to the redirect_uri after sign-in (Stan Hu) +- Fix file upload dialog for comment editing (Daniel Gerhardt) +- Set OmniAuth full_host parameter to ensure redirect URIs are correct (Stan Hu) +- Return comments in created order in merge request API (Stan Hu) +- Disable internal issue tracker controller if external tracker is used (Stan Hu) +- Expire Rails cache entries after two weeks to prevent endless Redis growth +- Add support for destroying project milestones (Stan Hu) +- Allow custom backup archive permissions +- Add project star and fork count, group avatar URL and user/group web URL attributes to API +- Show who last edited a comment if it wasn't the original author +- Send notification to all participants when MR is merged. +- Add ability to manage user email addresses via the API. +- Show buttons to add license, changelog and contribution guide if they're missing. +- Tweak project page buttons. +- Disabled autocapitalize and autocorrect on login field (Daryl Chan) +- Mention group and project name in creation, update and deletion notices (Achilleas Pipinellis) +- Update gravatar link on profile page to link to configured gravatar host (Ben Bodenmiller) +- Remove redis-store TTL monkey patch +- Add support for CI skipped status +- Fetch code from forks to refs/merge-requests/:id/head when merge request created +- Remove comments and email addresses when publicly exposing ssh keys (Zeger-Jan van de Weg) +- Add "Check out branch" button to the MR page. +- Improve MR merge widget text and UI consistency. +- Improve text in MR "How To Merge" modal. +- Cache all events +- Order commits by date when comparing branches +- Fix bug causing error when the target branch of a symbolic ref was deleted +- Include branch/tag name in archive file and directory name +- Add dropzone upload progress +- Add a label for merged branches on branches page (Florent Baldino) +- Detect .mkd and .mkdn files as markdown (Ben Boeckel) +- Fix: User search feature in admin area does not respect filters +- Set max-width for README, issue and merge request description for easier read on big screens +- Update Flowdock integration to support new Flowdock API (Boyan Tabakov) +- Remove author from files view (Sven Strickroth) +- Fix infinite loop when SAML was incorrectly configured. + +## 7.13.5 + +- Satellites reverted + +## 7.13.4 + +- Allow users to send abuse reports + +## 7.13.3 + +- Fix bug causing Bitbucket importer to crash when OAuth application had been removed. +- Allow users to send abuse reports +- Remove satellites +- Link username to profile on Group Members page (Tom Webster) + +## 7.13.2 + +- Fix randomly failed spec +- Create project services on Project creation +- Add admin_merge_request ability to Developer level and up +- Fix Error 500 when browsing projects with no HEAD (Stan Hu) +- Fix labels / assignee / milestone for the merge requests when issues are disabled +- Show the first tab automatically on MergeRequests#new +- Add rake task 'gitlab:update_commit_count' (Daniel Gerhardt) +- Fix Gmail Actions + +## 7.13.1 + +- Fix: Label modifications are not reflected in existing notes and in the issue list +- Fix: Label not shown in the Issue list, although it's set through web interface +- Fix: Group/project references are linked incorrectly +- Improve documentation +- Fix of migration: Check if session_expire_delay column exists before adding the column +- Fix: ActionView::Template::Error +- Fix: "Create Merge Request" isn't always shown in event for newly pushed branch +- Fix bug causing "Remove source-branch" option not to work for merge requests from the same project. +- Render Note field hints consistently for "new" and "edit" forms + +## 7.13.0 + +- Remove repository graph log to fix slow cache updates after push event (Stan Hu) +- Only enable HSTS header for HTTPS and port 443 (Stan Hu) +- Fix user autocomplete for unauthenticated users accessing public projects (Stan Hu) +- Fix redirection to home page URL for unauthorized users (Daniel Gerhardt) +- Add branch switching support for graphs (Daniel Gerhardt) +- Fix external issue tracker hook/test for HTTPS URLs (Daniel Gerhardt) +- Remove link leading to a 404 error in Deploy Keys page (Stan Hu) +- Add support for unlocking users in admin settings (Stan Hu) +- Add Irker service configuration options (Stan Hu) +- Fix order of issues imported from GitHub (Hiroyuki Sato) +- Bump rugments to 1.0.0beta8 to fix C prototype function highlighting (Jonathon Reinhart) +- Fix Merge Request webhook to properly fire "merge" action when accepted from the web UI +- Add `two_factor_enabled` field to admin user API (Stan Hu) +- Fix invalid timestamps in RSS feeds (Rowan Wookey) +- Fix downloading of patches on public merge requests when user logged out (Stan Hu) +- Fix Error 500 when relative submodule resolves to a namespace that has a different name from its path (Stan Hu) +- Extract the longest-matching ref from a commit path when multiple matches occur (Stan Hu) +- Update maintenance documentation to explain no need to recompile asssets for omnibus installations (Stan Hu) +- Support commenting on diffs in side-by-side mode (Stan Hu) +- Fix JavaScript error when clicking on the comment button on a diff line that has a comment already (Stan Hu) +- Return 40x error codes if branch could not be deleted in UI (Stan Hu) +- Remove project visibility icons from dashboard projects list +- Rename "Design" profile settings page to "Preferences". +- Allow users to customize their default Dashboard page. +- Update ssl_ciphers in Nginx example to remove DHE settings. This will deny forward secrecy for Android 2.3.7, Java 6 and OpenSSL 0.9.8 +- Admin can edit and remove user identities +- Convert CRLF newlines to LF when committing using the web editor. +- API request /projects/:project_id/merge_requests?state=closed will return only closed merge requests without merged one. If you need ones that were merged - use state=merged. +- Allow Administrators to filter the user list by those with or without Two-factor Authentication enabled. +- Show a user's Two-factor Authentication status in the administration area. +- Explicit error when commit not found in the CI +- Improve performance for issue and merge request pages +- Users with guest access level can not set assignee, labels or milestones for issue and merge request +- Reporter role can manage issue tracker now: edit any issue, set assignee or milestone and manage labels +- Better performance for pages with events list, issues list and commits list +- Faster automerge check and merge itself when source and target branches are in same repository +- Correctly show anonymous authorized applications under Profile > Applications. +- Query Optimization in MySQL. +- Allow users to be blocked and unblocked via the API +- Use native Postgres database cleaning during backup restore +- Redesign project page. Show README as default instead of activity. Move project activity to separate page +- Make left menu more hierarchical and less contextual by adding back item at top +- A fork can’t have a visibility level that is greater than the original project. +- Faster code search in repository and wiki. Fixes search page timeout for big repositories +- Allow administrators to disable 2FA for a specific user +- Add error message for SSH key linebreaks +- Store commits count in database (will populate with valid values only after first push) +- Rebuild cache after push to repository in background job +- Fix transferring of project to another group using the API. + +## 7.12.2 + +- Correctly show anonymous authorized applications under Profile > Applications. +- Faster automerge check and merge itself when source and target branches are in same repository +- Audit log for user authentication +- Allow custom label to be set for authentication providers. + +## 7.12.1 + +- Fix error when deleting a user who has projects (Stan Hu) +- Fix post-receive errors on a push when an external issue tracker is configured (Stan Hu) +- Add SAML to list of social_provider (Matt Firtion) +- Fix merge requests API scope to keep compatibility in 7.12.x patch release (Dmitriy Zaporozhets) +- Fix closed merge request scope at milestone page (Dmitriy Zaporozhets) +- Revert merge request states renaming +- Fix hooks for web based events with external issue references (Daniel Gerhardt) +- Improve performance for issue and merge request pages +- Compress database dumps to reduce backup size + +## 7.12.0 + +- Fix Error 500 when one user attempts to access a personal, internal snippet (Stan Hu) +- Disable changing of target branch in new merge request page when a branch has already been specified (Stan Hu) +- Fix post-receive errors on a push when an external issue tracker is configured (Stan Hu) +- Update oauth button logos for Twitter and Google to recommended assets +- Update browser gem to version 0.8.0 for IE11 support (Stan Hu) +- Fix timeout when rendering file with thousands of lines. +- Add "Remember me" checkbox to LDAP signin form. +- Add session expiration delay configuration through UI application settings +- Don't notify users mentioned in code blocks or blockquotes. +- Omit link to generate labels if user does not have access to create them (Stan Hu) +- Show warning when a comment will add 10 or more people to the discussion. +- Disable changing of the source branch in merge request update API (Stan Hu) +- Shorten merge request WIP text. +- Add option to disallow users from registering any application to use GitLab as an OAuth provider +- Support editing target branch of merge request (Stan Hu) +- Refactor permission checks with issues and merge requests project settings (Stan Hu) +- Fix Markdown preview not working in Edit Milestone page (Stan Hu) +- Fix Zen Mode not closing with ESC key (Stan Hu) +- Allow HipChat API version to be blank and default to v2 (Stan Hu) +- Add file attachment support in Milestone description (Stan Hu) +- Fix milestone "Browse Issues" button. +- Set milestone on new issue when creating issue from index with milestone filter active. +- Make namespace API available to all users (Stan Hu) +- Add webhook support for note events (Stan Hu) +- Disable "New Issue" and "New Merge Request" buttons when features are disabled in project settings (Stan Hu) +- Remove Rack Attack monkey patches and bump to version 4.3.0 (Stan Hu) +- Fix clone URL losing selection after a single click in Safari and Chrome (Stan Hu) +- Fix git blame syntax highlighting when different commits break up lines (Stan Hu) +- Add "Resend confirmation e-mail" link in profile settings (Stan Hu) +- Allow to configure location of the `.gitlab_shell_secret` file. (Jakub Jirutka) +- Disabled expansion of top/bottom blobs for new file diffs +- Update Asciidoctor gem to version 1.5.2. (Jakub Jirutka) +- Fix resolving of relative links to repository files in AsciiDoc documents. (Jakub Jirutka) +- Use the user list from the target project in a merge request (Stan Hu) +- Default extention for wiki pages is now .md instead of .markdown (Jeroen van Baarsen) +- Add validation to wiki page creation (only [a-zA-Z0-9/_-] are allowed) (Jeroen van Baarsen) +- Fix new/empty milestones showing 100% completion value (Jonah Bishop) +- Add a note when an Issue or Merge Request's title changes +- Consistently refer to MRs as either Merged or Closed. +- Add Merged tab to MR lists. +- Prefix EmailsOnPush email subject with `[Git]`. +- Group project contributions by both name and email. +- Clarify navigation labels for Project Settings and Group Settings. +- Move user avatar and logout button to sidebar +- You can not remove user if he/she is an only owner of group +- User should be able to leave group. If not - show him proper message +- User has ability to leave project +- Add SAML support as an omniauth provider +- Allow to configure a URL to show after sign out +- Add an option to automatically sign-in with an Omniauth provider +- GitLab CI service sends .gitlab-ci.yml in each push call +- When remove project - move repository and schedule it removal +- Improve group removing logic +- Trigger create-hooks on backup restore task +- Add option to automatically link omniauth and LDAP identities +- Allow special character in users bio. I.e.: I <3 GitLab + +## 7.11.4 + +- Fix missing bullets when creating lists +- Set rel="nofollow" on external links + +## 7.11.3 + +- no changes +- Fix upgrader script (Martins Polakovs) + +## 7.11.2 + +- no changes + +## 7.11.1 + +- no changes + +## 7.11.0 + +- Fall back to Plaintext when Syntaxhighlighting doesn't work. Fixes some buggy lexers (Hannes Rosenögger) +- Get editing comments to work in Chrome 43 again. +- Fix broken view when viewing history of a file that includes a path that used to be another file (Stan Hu) +- Don't show duplicate deploy keys +- Fix commit time being displayed in the wrong timezone in some cases (Hannes Rosenögger) +- Make the first branch pushed to an empty repository the default HEAD (Stan Hu) +- Fix broken view when using a tag to display a tree that contains git submodules (Stan Hu) +- Make Reply-To config apply to change e-mail confirmation and other Devise notifications (Stan Hu) +- Add application setting to restrict user signups to e-mail domains (Stan Hu) +- Don't allow a merge request to be merged when its title starts with "WIP". +- Add a page title to every page. +- Allow primary email to be set to an email that you've already added. +- Fix clone URL field and X11 Primary selection (Dmitry Medvinsky) +- Ignore invalid lines in .gitmodules +- Fix "Cannot move project" error message from popping up after a successful transfer (Stan Hu) +- Redirect to sign in page after signing out. +- Fix "Hello @username." references not working by no longer allowing usernames to end in period. +- Fix "Revspec not found" errors when viewing diffs in a forked project with submodules (Stan Hu) +- Improve project page UI +- Fix broken file browsing with relative submodule in personal projects (Stan Hu) +- Add "Reply quoting selected text" shortcut key (`r`) +- Fix bug causing `@whatever` inside an issue's first code block to be picked up as a user mention. +- Fix bug causing `@whatever` inside an inline code snippet (backtick-style) to be picked up as a user mention. +- When use change branches link at MR form - save source branch selection instead of target one +- Improve handling of large diffs +- Added GitLab Event header for project hooks +- Add Two-factor authentication (2FA) for GitLab logins +- Show Atom feed buttons everywhere where applicable. +- Add project activity atom feed. +- Don't crash when an MR from a fork has a cross-reference comment from the target project on one of its commits. +- Explain how to get a new password reset token in welcome emails +- Include commit comments in MR from a forked project. +- Group milestones by title in the dashboard and all other issue views. +- Query issues, merge requests and milestones with their IID through API (Julien Bianchi) +- Add default project and snippet visibility settings to the admin web UI. +- Show incompatible projects in Google Code import status (Stan Hu) +- Fix bug where commit data would not appear in some subdirectories (Stan Hu) +- Task lists are now usable in comments, and will show up in Markdown previews. +- Fix bug where avatar filenames were not actually deleted from the database during removal (Stan Hu) +- Fix bug where Slack service channel was not saved in admin template settings. (Stan Hu) +- Protect OmniAuth request phase against CSRF. +- Don't send notifications to mentioned users that don't have access to the project in question. +- Add search issues/MR by number +- Change plots to bar graphs in commit statistics screen +- Move snippets UI to fluid layout +- Improve UI for sidebar. Increase separation between navigation and content +- Improve new project command options (Ben Bodenmiller) +- Add common method to force UTF-8 and use it to properly handle non-ascii OAuth user properties (Onur Küçük) +- Prevent sending empty messages to HipChat (Chulki Lee) +- Improve UI for mobile phones on dashboard and project pages +- Add room notification and message color option for HipChat +- Allow to use non-ASCII letters and dashes in project and namespace name. (Jakub Jirutka) +- Add footnotes support to Markdown (Guillaume Delbergue) +- Add current_sign_in_at to UserFull REST api. +- Make Sidekiq MemoryKiller shutdown signal configurable +- Add "Create Merge Request" buttons to commits and branches pages and push event. +- Show user roles by comments. +- Fix automatic blocking of auto-created users from Active Directory. +- Call merge request webhook for each new commits (Arthur Gautier) +- Use SIGKILL by default in Sidekiq::MemoryKiller +- Fix mentioning of private groups. +- Add style for <kbd> element in markdown +- Spin spinner icon next to "Checking for CI status..." on MR page. +- Fix reference links in dashboard activity and ATOM feeds. +- Ensure that the first added admin performs repository imports + +## 7.10.4 + +- Fix migrations broken in 7.10.2 +- Make tags for GitLab installations running on MySQL case sensitive +- Get Gitorious importer to work again. +- Fix adding new group members from admin area +- Fix DB error when trying to tag a repository (Stan Hu) +- Fix Error 500 when searching Wiki pages (Stan Hu) +- Unescape branch names in compare commit (Stan Hu) +- Order commit comments chronologically in API. + +## 7.10.2 + +- Fix CI links on MR page + +## 7.10.0 + +- Ignore submodules that are defined in .gitmodules but are checked in as directories. +- Allow projects to be imported from Google Code. +- Remove access control for uploaded images to fix broken images in emails (Hannes Rosenögger) +- Allow users to be invited by email to join a group or project. +- Don't crash when project repository doesn't exist. +- Add config var to block auto-created LDAP users. +- Don't use HTML ellipsis in EmailsOnPush subject truncated commit message. +- Set EmailsOnPush reply-to address to committer email when enabled. +- Fix broken file browsing with a submodule that contains a relative link (Stan Hu) +- Fix persistent XSS vulnerability around profile website URLs. +- Fix project import URL regex to prevent arbitary local repos from being imported. +- Fix directory traversal vulnerability around uploads routes. +- Fix directory traversal vulnerability around help pages. +- Don't leak existence of project via search autocomplete. +- Don't leak existence of group or project via search. +- Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) +- Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) +- Add a rake task to check repository integrity with `git fsck` +- Add ability to configure Reply-To address in gitlab.yml (Stan Hu) +- Move current user to the top of the list in assignee/author filters (Stan Hu) +- Fix broken side-by-side diff view on merge request page (Stan Hu) +- Set Application controller default URL options to ensure all url_for calls are consistent (Stan Hu) +- Allow HTML tags in Markdown input +- Fix code unfold not working on Compare commits page (Stan Hu) +- Fix generating SSH key fingerprints with OpenSSH 6.8. (Sašo Stanovnik) +- Fix "Import projects from" button to show the correct instructions (Stan Hu) +- Fix dots in Wiki slugs causing errors (Stan Hu) +- Make maximum attachment size configurable via Application Settings (Stan Hu) +- Update poltergeist to version 1.6.0 to support PhantomJS 2.0 (Zeger-Jan van de Weg) +- Fix cross references when usernames, milestones, or project names contain underscores (Stan Hu) +- Disable reference creation for comments surrounded by code/preformatted blocks (Stan Hu) +- Reduce Rack Attack false positives causing 403 errors during HTTP authentication (Stan Hu) +- enable line wrapping per default and remove the checkbox to toggle it (Hannes Rosenögger) +- Fix a link in the patch update guide +- Add a service to support external wikis (Hannes Rosenögger) +- Omit the "email patches" link and fix plain diff view for merge commits +- List new commits for newly pushed branch in activity view. +- Add sidetiq gem dependency to match EE +- Add changelog, license and contribution guide links to project tab bar. +- Improve diff UI +- Fix alignment of navbar toggle button (Cody Mize) +- Fix checkbox rendering for nested task lists +- Identical look of selectboxes in UI +- Upgrade the gitlab_git gem to version 7.1.3 +- Move "Import existing repository by URL" option to button. +- Improve error message when save profile has error. +- Passing the name of pushed ref to CI service (requires GitLab CI 7.9+) +- Add location field to user profile +- Fix print view for markdown files and wiki pages +- Fix errors when deleting old backups +- Improve GitLab performance when working with git repositories +- Add tag message and last commit to tag hook (Kamil Trzciński) +- Restrict permissions on backup files +- Improve oauth accounts UI in profile page +- Add ability to unlink connected accounts +- Replace commits calendar with faster contribution calendar that includes issues and merge requests +- Add inifinite scroll to user page activity +- Don't include system notes in issue/MR comment count. +- Don't mark merge request as updated when merge status relative to target branch changes. +- Link note avatar to user. +- Make Git-over-SSH errors more descriptive. +- Fix EmailsOnPush. +- Refactor issue filtering +- AJAX selectbox for issue assignee and author filters +- Fix issue with missing options in issue filtering dropdown if selected one +- Prevent holding Control-Enter or Command-Enter from posting comment multiple times. +- Prevent note form from being cleared when submitting failed. +- Improve file icons rendering on tree (Sullivan Sénéchal) +- API: Add pagination to project events +- Get issue links in notification mail to work again. +- Don't show commit comment button when user is not signed in. +- Fix admin user projects lists. +- Don't leak private group existence by redirecting from namespace controller to group controller. +- Ability to skip some items from backup (database, respositories or uploads) +- Archive repositories in background worker. +- Import GitHub, Bitbucket or GitLab.com projects owned by authenticated user into current namespace. +- Project labels are now available over the API under the "tag_list" field (Cristian Medina) +- Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) +- Fix and improve help rendering (Sullivan Sénéchal) +- Fix final line in EmailsOnPush email diff being rendered as error. +- Prevent duplicate Buildkite service creation. +- Fix git over ssh errors 'fatal: protocol error: bad line length character' +- Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled +- Bust group page project list cache when namespace name or path changes. +- Explicitly set image alt-attribute to prevent graphical glitches if gravatars could not be loaded +- Allow user to choose a public email to show on public profile +- Remove truncation from issue titles on milestone page (Jason Blanchard) +- Fix stuck Merge Request merging events from old installations (Ben Bodenmiller) +- Fix merge request comments on files with multiple commits +- Fix Resource Owner Password Authentication Flow +- Add icons to Add dropdown items. +- Allow admin to create public deploy keys that are accessible to any project. +- Warn when gitlab-shell version doesn't match requirement. +- Skip email confirmation when set by admin or via LDAP. +- Only allow users to reference groups, projects, issues, MRs, commits they have access to. + +## 7.9.4 + +- Security: Fix project import URL regex to prevent arbitary local repos from being imported +- Fixed issue where only 25 commits would load in file listings +- Fix LDAP identities after config update + +## 7.9.3 + +- Contains no changes + +## 7.9.2 + +- Contains no changes + +## 7.9.1 + +- Include missing events and fix save functionality in admin service template settings form (Stan Hu) +- Fix "Import projects from" button to show the correct instructions (Stan Hu) +- Fix OAuth2 issue importing a new project from GitHub and GitLab (Stan Hu) +- Fix for LDAP with commas in DN +- Fix missing events and in admin Slack service template settings form (Stan Hu) +- Don't show commit comment button when user is not signed in. +- Downgrade gemnasium-gitlab-service gem + +## 7.9.0 + +- Add HipChat integration documentation (Stan Hu) +- Update documentation for object_kind field in Webhook push and tag push Webhooks (Stan Hu) +- Fix broken email images (Hannes Rosenögger) +- Automatically config git if user forgot, where possible (Zeger-Jan van de Weg) +- Fix mass SQL statements on initial push (Hannes Rosenögger) +- Add tag push notifications and normalize HipChat and Slack messages to be consistent (Stan Hu) +- Add comment notification events to HipChat and Slack services (Stan Hu) +- Add issue and merge request events to HipChat and Slack services (Stan Hu) +- Fix merge request URL passed to Webhooks. (Stan Hu) +- Fix bug that caused a server error when editing a comment to "+1" or "-1" (Stan Hu) +- Fix code preview theme setting for comments, issues, merge requests, and snippets (Stan Hu) +- Move labels/milestones tabs to sidebar +- Upgrade Rails gem to version 4.1.9. +- Improve error messages for file edit failures +- Improve UI for commits, issues and merge request lists +- Fix commit comments on first line of diff not rendering in Merge Request Discussion view. +- Allow admins to override restricted project visibility settings. +- Move restricted visibility settings from gitlab.yml into the web UI. +- Improve trigger merge request hook when source project branch has been updated (Kirill Zaitsev) +- Save web edit in new branch +- Fix ordering of imported but unchanged projects (Marco Wessel) +- Mobile UI improvements: make aside content expandable +- Expose avatar_url in projects API +- Fix checkbox alignment on the application settings page. +- Generalize image upload in drag and drop in markdown to all files (Hannes Rosenögger) +- Fix mass-unassignment of issues (Robert Speicher) +- Fix hidden diff comments in merge request discussion view +- Allow user confirmation to be skipped for new users via API +- Add a service to send updates to an Irker gateway (Romain Coltel) +- Add brakeman (security scanner for Ruby on Rails) +- Slack username and channel options +- Add grouped milestones from all projects to dashboard. +- Webhook sends pusher email as well as commiter +- Add Bitbucket omniauth provider. +- Add Bitbucket importer. +- Support referencing issues to a project whose name starts with a digit +- Condense commits already in target branch when updating merge request source branch. +- Send notifications and leave system comments when bulk updating issues. +- Automatically link commit ranges to compare page: sha1...sha4 or sha1..sha4 (includes sha1 in comparison) +- Move groups page from profile to dashboard +- Starred projects page at dashboard +- Blocking user does not remove him/her from project/groups but show blocked label +- Change subject of EmailsOnPush emails to include namespace, project and branch. +- Change subject of EmailsOnPush emails to include first commit message when multiple were pushed. +- Remove confusing footer from EmailsOnPush mail body. +- Add list of changed files to EmailsOnPush emails. +- Add option to send EmailsOnPush emails from committer email if domain matches. +- Add option to disable code diffs in EmailOnPush emails. +- Wrap commit message in EmailsOnPush email. +- Send EmailsOnPush emails when deleting commits using force push. +- Fix EmailsOnPush email comparison link to include first commit. +- Fix highliht of selected lines in file +- Reject access to group/project avatar if the user doesn't have access. +- Add database migration to clean group duplicates with same path and name (Make sure you have a backup before update) +- Add GitLab active users count to rake gitlab:check +- Starred projects page at dashboard +- Make email display name configurable +- Improve json validation in hook data +- Use Emoji One +- Updated emoji help documentation to properly reference EmojiOne. +- Fix missing GitHub organisation repositories on import page. +- Added blue theme +- Remove annoying notice messages when create/update merge request +- Allow smb:// links in Markdown text. +- Filter merge request by title or description at Merge Requests page +- Block user if he/she was blocked in Active Directory +- Fix import pages not working after first load. +- Use custom LDAP label in LDAP signin form. +- Execute hooks and services when branch or tag is created or deleted through web interface. +- Block and unblock user if he/she was blocked/unblocked in Active Directory +- Raise recommended number of unicorn workers from 2 to 3 +- Use same layout and interactivity for project members as group members. +- Prevent gitlab-shell character encoding issues by receiving its changes as raw data. +- Ability to unsubscribe/subscribe to issue or merge request +- Delete deploy key when last connection to a project is destroyed. +- Fix invalid Atom feeds when using emoji, horizontal rules, or images (Christian Walther) +- Backup of repositories with tar instead of git bundle (only now are git-annex files included in the backup) +- Add canceled status for CI +- Send EmailsOnPush email when branch or tag is created or deleted. +- Faster merge request processing for large repository +- Prevent doubling AJAX request with each commit visit via Turbolink +- Prevent unnecessary doubling of js events on import pages and user calendar + +## 7.8.4 + +- Fix issue_tracker_id substitution in custom issue trackers +- Fix path and name duplication in namespaces + +## 7.8.3 + +- Bump version of gitlab_git fixing annotated tags without message + +## 7.8.2 + +- Fix service migration issue when upgrading from versions prior to 7.3 +- Fix setting of the default use project limit via admin UI +- Fix showing of already imported projects for GitLab and Gitorious importers +- Fix response of push to repository to return "Not found" if user doesn't have access +- Fix check if user is allowed to view the file attachment +- Fix import check for case sensetive namespaces +- Increase timeout for Git-over-HTTP requests to 1 hour since large pulls/pushes can take a long time. +- Properly handle autosave local storage exceptions. +- Escape wildcards when searching LDAP by username. + +## 7.8.1 + +- Fix run of custom post receive hooks +- Fix migration that caused issues when upgrading to version 7.8 from versions prior to 7.3 +- Fix the warning for LDAP users about need to set password +- Fix avatars which were not shown for non logged in users +- Fix urls for the issues when relative url was enabled + +## 7.8.0 + +- Fix access control and protection against XSS for note attachments and other uploads. +- Replace highlight.js with rouge-fork rugments (Stefan Tatschner) +- Make project search case insensitive (Hannes Rosenögger) +- Include issue/mr participants in list of recipients for reassign/close/reopen emails +- Expose description in groups API +- Better UI for project services page +- Cleaner UI for web editor +- Add diff syntax highlighting in email-on-push service notifications (Hannes Rosenögger) +- Add API endpoint to fetch all changes on a MergeRequest (Jeroen van Baarsen) +- View note image attachments in new tab when clicked instead of downloading them +- Improve sorting logic in UI and API. Explicitly define what sorting method is used by default +- Fix overflow at sidebar when have several items +- Add notes for label changes in issue and merge requests +- Show tags in commit view (Hannes Rosenögger) +- Only count a user's vote once on a merge request or issue (Michael Clarke) +- Increase font size when browse source files and diffs +- Service Templates now let you set default values for all services +- Create new file in empty repository using GitLab UI +- Ability to clone project using oauth2 token +- Upgrade Sidekiq gem to version 3.3.0 +- Stop git zombie creation during force push check +- Show success/error messages for test setting button in services +- Added Rubocop for code style checks +- Fix commits pagination +- Async load a branch information at the commit page +- Disable blacklist validation for project names +- Allow configuring protection of the default branch upon first push (Marco Wessel) +- Add gitlab.com importer +- Add an ability to login with gitlab.com +- Add a commit calendar to the user profile (Hannes Rosenögger) +- Submit comment on command-enter +- Notify all members of a group when that group is mentioned in a comment, for example: `@gitlab-org` or `@sales`. +- Extend issue clossing pattern to include "Resolve", "Resolves", "Resolved", "Resolving" and "Close" (Julien Bianchi and Hannes Rosenögger) +- Fix long broadcast message cut-off on left sidebar (Visay Keo) +- Add Project Avatars (Steven Thonus and Hannes Rosenögger) +- Password reset token validity increased from 2 hours to 2 days since it is also send on account creation. +- Edit group members via API +- Enable raw image paste from clipboard, currently Chrome only (Marco Cyriacks) +- Add action property to merge request hook (Julien Bianchi) +- Remove duplicates from group milestone participants list. +- Add a new API function that retrieves all issues assigned to a single milestone (Justin Whear and Hannes Rosenögger) +- API: Access groups with their path (Julien Bianchi) +- Added link to milestone and keeping resource context on smaller viewports for issues and merge requests (Jason Blanchard) +- Allow notification email to be set separately from primary email. +- API: Add support for editing an existing project (Mika Mäenpää and Hannes Rosenögger) +- Don't have Markdown preview fail for long comments/wiki pages. +- When test webhook - show error message instead of 500 error page if connection to hook url was reset +- Added support for firing system hooks on group create/destroy and adding/removing users to group (Boyan Tabakov) +- Added persistent collapse button for left side nav bar (Jason Blanchard) +- Prevent losing unsaved comments by automatically restoring them when comment page is loaded again. +- Don't allow page to be scaled on mobile. +- Clean the username acquired from OAuth/LDAP so it doesn't fail username validation and block signing up. +- Show assignees in merge request index page (Kelvin Mutuma) +- Link head panel titles to relevant root page. +- Allow users that signed up via OAuth to set their password in order to use Git over HTTP(S). +- Show users button to share their newly created public or internal projects on twitter +- Add quick help links to the GitLab pricing and feature comparison pages. +- Fix duplicate authorized applications in user profile and incorrect application client count in admin area. +- Make sure Markdown previews always use the same styling as the eventual destination. +- Remove deprecated Group#owner_id from API +- Show projects user contributed to on user page. Show stars near project on user page. +- Improve database performance for GitLab +- Add Asana service (Jeremy Benoist) +- Improve project webhooks with extra data + +## 7.7.2 + +- Update GitLab Shell to version 2.4.2 that fixes a bug when developers can push to protected branch +- Fix issue when LDAP user can't login with existing GitLab account + +## 7.7.1 + +- Improve mention autocomplete performance +- Show setup instructions for GitHub import if disabled +- Allow use http for OAuth applications + +## 7.7.0 + +- Import from GitHub.com feature +- Add Jetbrains Teamcity CI service (Jason Lippert) +- Mention notification level +- Markdown preview in wiki (Yuriy Glukhov) +- Raise group avatar filesize limit to 200kb +- OAuth applications feature +- Show user SSH keys in admin area +- Developer can push to protected branches option +- Set project path instead of project name in create form +- Block Git HTTP access after 10 failed authentication attempts +- Updates to the messages returned by API (sponsored by O'Reilly Media) +- New UI layout with side navigation +- Add alert message in case of outdated browser (IE < 10) +- Added API support for sorting projects +- Update gitlab_git to version 7.0.0.rc14 +- Add API project search filter option for authorized projects +- Fix File blame not respecting branch selection +- Change some of application settings on fly in admin area UI +- Redesign signin/signup pages +- Close standard input in Gitlab::Popen.popen +- Trigger GitLab CI when push tags +- When accept merge request - do merge using sidaekiq job +- Enable web signups by default +- Fixes for diff comments: drag-n-drop images, selecting images +- Fixes for edit comments: drag-n-drop images, preview mode, selecting images, save & update +- Remove password strength indicator + +## 7.6.0 + +- Fork repository to groups +- New rugged version +- Add CRON=1 backup setting for quiet backups +- Fix failing wiki restore +- Add optional Sidekiq MemoryKiller middleware (enabled via SIDEKIQ_MAX_RSS env variable) +- Monokai highlighting style now more faithful to original design (Mark Riedesel) +- Create project with repository in synchrony +- Added ability to create empty repo or import existing one if project does not have repository +- Reactivate highlight.js language autodetection +- Mobile UI improvements +- Change maximum avatar file size from 100KB to 200KB +- Strict validation for snippet file names +- Enable Markdown preview for issues, merge requests, milestones, and notes (Vinnie Okada) +- In the docker directory is a container template based on the Omnibus packages. +- Update Sidekiq to version 2.17.8 +- Add author filter to project issues and merge requests pages +- Atom feed for user activity +- Support multiple omniauth providers for the same user +- Rendering cross reference in issue title and tooltip for merge request +- Show username in comments +- Possibility to create Milestones or Labels when Issues are disabled +- Fix bug with showing gpg signature in tag + +## 7.5.3 + +- Bump gitlab_git to 7.0.0.rc12 (includes Rugged 0.21.2) + +## 7.5.2 + +- Don't log Sidekiq arguments by default +- Fix restore of wiki repositories from backups + +## 7.5.1 + +- Add missing timestamps to 'members' table + +## 7.5.0 + +- API: Add support for Hipchat (Kevin Houdebert) +- Add time zone configuration in gitlab.yml (Sullivan Senechal) +- Fix LDAP authentication for Git HTTP access +- Run 'GC.start' after every EmailsOnPushWorker job +- Fix LDAP config lookup for provider 'ldap' +- Drop all sequences during Postgres database restore +- Project title links to project homepage (Ben Bodenmiller) +- Add Atlassian Bamboo CI service (Drew Blessing) +- Mentioned @user will receive email even if he is not participating in issue or commit +- Session API: Use case-insensitive authentication like in UI (Andrey Krivko) +- Tie up loose ends with annotated tags: API & UI (Sean Edge) +- Return valid json for deleting branch via API (sponsored by O'Reilly Media) +- Expose username in project events API (sponsored by O'Reilly Media) +- Adds comments to commits in the API +- Performance improvements +- Fix post-receive issue for projects with deleted forks +- New gitlab-shell version with custom hooks support +- Improve code +- GitLab CI 5.2+ support (does not support older versions) +- Fixed bug when you can not push commits starting with 000000 to protected branches +- Added a password strength indicator +- Change project name and path in one form +- Display renamed files in diff views (Vinnie Okada) +- Fix raw view for public snippets +- Use secret token with GitLab internal API. +- Add missing timestamps to 'members' table + +## 7.4.5 + +- Bump gitlab_git to 7.0.0.rc12 (includes Rugged 0.21.2) + +## 7.4.4 + +- No changes + +## 7.4.3 + +- Fix raw snippets view +- Fix security issue for member api +- Fix buildbox integration + +## 7.4.2 + +- Fix internal snippet exposing for unauthenticated users + +## 7.4.1 + +- Fix LDAP authentication for Git HTTP access +- Fix LDAP config lookup for provider 'ldap' +- Fix public snippets +- Fix 500 error on projects with nested submodules + +## 7.4.0 + +- Refactored membership logic +- Improve error reporting on users API (Julien Bianchi) +- Refactor test coverage tools usage. Use SIMPLECOV=true to generate it locally +- Default branch is protected by default +- Increase unicorn timeout to 60 seconds +- Sort search autocomplete projects by stars count so most popular go first +- Add README to tab on project show page +- Do not delete tmp/repositories itself during clean-up, only its contents +- Support for backup uploads to remote storage +- Prevent notes polling when there are not notes +- Internal ForkService: Prepare support for fork to a given namespace +- API: Add support for forking a project via the API (Bernhard Kaindl) +- API: filter project issues by milestone (Julien Bianchi) +- Fail harder in the backup script +- Changes to Slack service structure, only webhook url needed +- Zen mode for wiki and milestones (Robert Schilling) +- Move Emoji parsing to html-pipeline-gitlab (Robert Schilling) +- Font Awesome 4.2 integration (Sullivan Senechal) +- Add Pushover service integration (Sullivan Senechal) +- Add select field type for services options (Sullivan Senechal) +- Add cross-project references to the Markdown parser (Vinnie Okada) +- Add task lists to issue and merge request descriptions (Vinnie Okada) +- Snippets can be public, internal or private +- Improve danger zone: ask project path to confirm data-loss action +- Raise exception on forgery +- Show build coverage in Merge Requests (requires GitLab CI v5.1) +- New milestone and label links on issue edit form +- Improved repository graphs +- Improve event note display in dashboard and project activity views (Vinnie Okada) +- Add users sorting to admin area +- UI improvements +- Fix ambiguous sha problem with mentioned commit +- Fixed bug with apostrophe when at mentioning users +- Add active directory ldap option +- Developers can push to wiki repo. Protected branches does not affect wiki repo any more +- Faster rev list +- Fix branch removal + +## 7.3.2 + +- Fix creating new file via web editor +- Use gitlab-shell v2.0.1 + +## 7.3.1 + +- Fix ref parsing in Gitlab::GitAccess +- Fix error 500 when viewing diff on a file with changed permissions +- Fix adding comments to MR when source branch is master +- Fix error 500 when searching description contains relative link + +## 7.3.0 + +- Always set the 'origin' remote in satellite actions +- Write authorized_keys in tmp/ during tests +- Use sockets to connect to Redis +- Add dormant New Relic gem (can be enabled via environment variables) +- Expire Rack sessions after 1 week +- Cleaner signin/signup pages +- Improved comments UI +- Better search with filtering, pagination etc +- Added a checkbox to toggle line wrapping in diff (Yuriy Glukhov) +- Prevent project stars duplication when fork project +- Use the default Unicorn socket backlog value of 1024 +- Support Unix domain sockets for Redis +- Store session Redis keys in 'session:gitlab:' namespace +- Deprecate LDAP account takeover based on partial LDAP email / GitLab username match +- Use /bin/sh instead of Bash in bin/web, bin/background_jobs (Pavel Novitskiy) +- Keyboard shortcuts for productivity (Robert Schilling) +- API: filter issues by state (Julien Bianchi) +- API: filter issues by labels (Julien Bianchi) +- Add system hook for ssh key changes +- Add blob permalink link (Ciro Santilli) +- Create annotated tags through UI and API (Sean Edge) +- Snippets search (Charles Bushong) +- Comment new push to existing MR +- Add 'ci' to the blacklist of forbidden names +- Improve text filtering on issues page +- Comment & Close button +- Process git push --all much faster +- Don't allow edit of system notes +- Project wiki search (Ralf Seidler) +- Enabled Shibboleth authentication support (Matus Banas) +- Zen mode (fullscreen) for issues/MR/notes (Robert Schilling) +- Add ability to configure webhook timeout via gitlab.yml (Wes Gurney) +- Sort project merge requests in asc or desc order for updated_at or created_at field (sponsored by O'Reilly Media) +- Add Redis socket support to 'rake gitlab:shell:install' + +## 7.2.1 + +- Delete orphaned labels during label migration (James Brooks) +- Security: prevent XSS with stricter MIME types for raw repo files + +## 7.2.0 + +- Explore page +- Add project stars (Ciro Santilli) +- Log Sidekiq arguments +- Better labels: colors, ability to rename and remove +- Improve the way merge request collects diffs +- Improve compare page for large diffs +- Expose the full commit message via API +- Fix 500 error on repository rename +- Fix bug when MR download patch return invalid diff +- Test gitlab-shell integration +- Repository import timeout increased from 2 to 4 minutes allowing larger repos to be imported +- API for labels (Robert Schilling) +- API: ability to set an import url when creating project for specific user + +## 7.1.1 + +- Fix cpu usage issue in Firefox +- Fix redirect loop when changing password by new user +- Fix 500 error on new merge request page + +## 7.1.0 + +- Remove observers +- Improve MR discussions +- Filter by description on Issues#index page +- Fix bug with namespace select when create new project page +- Show README link after description for non-master members +- Add @all mention for comments +- Dont show reply button if user is not signed in +- Expose more information for issues with webhook +- Add a mention of the merge request into the default merge request commit message +- Improve code highlight, introduce support for more languages like Go, Clojure, Erlang etc +- Fix concurrency issue in repository download +- Dont allow repository name start with ? +- Improve email threading (Pierre de La Morinerie) +- Cleaner help page +- Group milestones +- Improved email notifications +- Contributors API (sponsored by Mobbr) +- Fix LDAP TLS authentication (Boris HUISGEN) +- Show VERSION information on project sidebar +- Improve branch removal logic when accept MR +- Fix bug where comment form is spawned inside the Reply button +- Remove Dir.chdir from Satellite#lock for thread-safety +- Increased default git max_size value from 5MB to 20MB in gitlab.yml. Please update your configs! +- Show error message in case of timeout in satellite when create MR +- Show first 100 files for huge diff instead of hiding all +- Change default admin email from admin@local.host to admin@example.com + +## 7.0.0 + +- The CPU no longer overheats when you hold down the spacebar +- Improve edit file UI +- Add ability to upload group avatar when create +- Protected branch cannot be removed +- Developers can remove normal branches with UI +- Remove branch via API (sponsored by O'Reilly Media) +- Move protected branches page to Project settings area +- Redirect to Files view when create new branch via UI +- Drag and drop upload of image in every markdown-area (Earle Randolph Bunao and Neil Francis Calabroso) +- Refactor the markdown relative links processing +- Make it easier to implement other CI services for GitLab +- Group masters can create projects in group +- Deprecate ruby 1.9.3 support +- Only masters can rewrite/remove git tags +- Add X-Frame-Options SAMEORIGIN to Nginx config so Sidekiq admin is visible +- UI improvements +- Case-insensetive search for issues +- Update to rails 4.1 +- Improve performance of application for projects and groups with a lot of members +- Formally support Ruby 2.1 +- Include Nginx gitlab-ssl config +- Add manual language detection for highlight.js +- Added example.com/:username routing +- Show notice if your profile is public +- UI improvements for mobile devices +- Improve diff rendering performance +- Drag-n-drop for issues and merge requests between states at milestone page +- Fix '0 commits' message for huge repositories on project home page +- Prevent 500 error page when visit commit page from large repo +- Add notice about huge push over http to unicorn config +- File action in satellites uses default 30 seconds timeout instead of old 10 seconds one +- Overall performance improvements +- Skip init script check on omnibus-gitlab +- Be more selective when killing stray Sidekiqs +- Check LDAP user filter during sign-in +- Remove wall feature (no data loss - you can take it from database) +- Dont expose user emails via API unless you are admin +- Detect issues closed by Merge Request description +- Better email subject lines from email on push service (Alex Elman) +- Enable identicon for gravatar be default + +## 6.9.2 + +- Revert the commit that broke the LDAP user filter + +## 6.9.1 + +- Fix scroll to highlighted line +- Fix the pagination on load for commits page + +## 6.9.0 + +- Store Rails cache data in the Redis `cache:gitlab` namespace +- Adjust MySQL limits for existing installations +- Add db index on project_id+iid column. This prevents duplicate on iid (During migration duplicates will be removed) +- Markdown preview or diff during editing via web editor (Evgeniy Sokovikov) +- Give the Rails cache its own Redis namespace +- Add ability to set different ssh host, if different from http/https +- Fix syntax highlighting for code comments blocks +- Improve comments loading logic +- Stop refreshing comments when the tab is hidden +- Improve issue and merge request mobile UI (Drew Blessing) +- Document how to convert a backup to PostgreSQL +- Fix locale bug in backup manager +- Fix can not automerge when MR description is too long +- Fix wiki backup skip bug +- Two Step MR creation process +- Remove unwanted files from satellite working directory with git clean -fdx +- Accept merge request via API (sponsored by O'Reilly Media) +- Add more access checks during API calls +- Block SSH access for 'disabled' Active Directory users +- Labels for merge requests (Drew Blessing) +- Threaded emails by setting a Message-ID (Philip Blatter) + +## 6.8.0 + +- Ability to at mention users that are participating in issue and merge req. discussion +- Enabled GZip Compression for assets in example Nginx, make sure that Nginx is compiled with --with-http_gzip_static_module flag (this is default in Ubuntu) +- Make user search case-insensitive (Christopher Arnold) +- Remove omniauth-ldap nickname bug workaround +- Drop all tables before restoring a Postgres backup +- Make the repository downloads path configurable +- Create branches via API (sponsored by O'Reilly Media) +- Changed permission of gitlab-satellites directory not to be world accessible +- Protected branch does not allow force push +- Fix popen bug in `rake gitlab:satellites:create` +- Disable connection reaping for MySQL +- Allow oauth signup without email for twitter and github +- Fix faulty namespace names that caused 500 on user creation +- Option to disable standard login +- Clean old created archives from repository downloads directory +- Fix download link for huge MR diffs +- Expose event and mergerequest timestamps in API +- Fix emails on push service when only one commit is pushed + +## 6.7.3 + +- Fix the merge notification email not being sent (Pierre de La Morinerie) +- Drop all tables before restoring a Postgres backup +- Remove yanked modernizr gem + +## 6.7.2 + +- Fix upgrader script + +## 6.7.1 + +- Fix GitLab CI integration + +## 6.7.0 + +- Increased the example Nginx client_max_body_size from 5MB to 20MB, consider updating it manually on existing installations +- Add support for Gemnasium as a Project Service (Olivier Gonzalez) +- Add edit file button to MergeRequest diff +- Public groups (Jason Hollingsworth) +- Cleaner headers in Notification Emails (Pierre de La Morinerie) +- Blob and tree gfm links to anchors work +- Piwik Integration (Sebastian Winkler) +- Show contribution guide link for new issue form (Jeroen van Baarsen) +- Fix CI status for merge requests from fork +- Added option to remove issue assignee on project issue page and issue edit page (Jason Blanchard) +- New page load indicator that includes a spinner that scrolls with the page +- Converted all the help sections into markdown +- LDAP user filters +- Streamline the content of notification emails (Pierre de La Morinerie) +- Fixes a bug with group member administration (Matt DeTullio) +- Sort tag names using VersionSorter (Robert Speicher) +- Add GFM autocompletion for MergeRequests (Robert Speicher) +- Add webhook when a new tag is pushed (Jeroen van Baarsen) +- Add button for toggling inline comments in diff view +- Add retry feature for repository import +- Reuse the GitLab LDAP connection within each request +- Changed markdown new line behaviour to conform to markdown standards +- Fix global search +- Faster authorized_keys rebuilding in `rake gitlab:shell:setup` (requires gitlab-shell 1.8.5) +- Create and Update MR calls now support the description parameter (Greg Messner) +- Markdown relative links in the wiki link to wiki pages, markdown relative links in repositories link to files in the repository +- Added Slack service integration (Federico Ravasio) +- Better API responses for access_levels (sponsored by O'Reilly Media) +- Requires at least 2 unicorn workers +- Requires gitlab-shell v1.9+ +- Replaced gemoji(due to closed licencing problem) with Phantom Open Emoji library(combined SIL Open Font License, MIT License and the CC 3.0 License) +- Fix `/:username.keys` response content type (Dmitry Medvinsky) + +## 6.6.5 + +- Added option to remove issue assignee on project issue page and issue edit page (Jason Blanchard) +- Hide mr close button for comment form if merge request was closed or inline comment +- Adds ability to reopen closed merge request + +## 6.6.4 + +- Add missing html escape for highlighted code blocks in comments, issues + +## 6.6.3 + +- Fix 500 error when edit yourself from admin area +- Hide private groups for public profiles + +## 6.6.2 + +- Fix 500 error on branch/tag create or remove via UI + +## 6.6.1 + +- Fix 500 error on files tab if submodules presents + +## 6.6.0 + +- Retrieving user ssh keys publically(github style): http://__HOST__/__USERNAME__.keys +- Permissions: Developer now can manage issue tracker (modify any issue) +- Improve Code Compare page performance +- Group avatar +- Pygments.rb replaced with highlight.js +- Improve Merge request diff store logic +- Improve render performnace for MR show page +- Fixed Assembla hardcoded project name +- Jira integration documentation +- Refactored app/services +- Remove snippet expiration +- Mobile UI improvements (Drew Blessing) +- Fix block/remove UI for admin::users#show page +- Show users' group membership on users' activity page (Robert Djurasaj) +- User pages are visible without login if user is authorized to a public project +- Markdown rendered headers have id derived from their name and link to their id +- Improve application to work faster with large groups (100+ members) +- Multiple emails per user +- Show last commit for file when view file source +- Restyle Issue#show page and MR#show page +- Ability to filter by multiple labels for Issues page +- Rails version to 4.0.3 +- Fixed attachment identifier displaying underneath note text (Jason Blanchard) + +## 6.5.1 + +- Fix branch selectbox when create merge request from fork + +## 6.5.0 + +- Dropdown menus on issue#show page for assignee and milestone (Jason Blanchard) +- Add color custimization and previewing to broadcast messages +- Fixed notes anchors +- Load new comments in issues dynamically +- Added sort options to Public page +- New filters (assigned/authored/all) for Dashboard#issues/merge_requests (sponsored by Say Media) +- Add project visibility icons to dashboard +- Enable secure cookies if https used +- Protect users/confirmation with rack_attack +- Default HTTP headers to protect against MIME-sniffing, force https if enabled +- Bootstrap 3 with responsive UI +- New repository download formats: tar.bz2, zip, tar (Jason Hollingsworth) +- Restyled accept widgets for MR +- SCSS refactored +- Use jquery timeago plugin +- Fix 500 error for rdoc files +- Ability to customize merge commit message (sponsored by Say Media) +- Search autocomplete via ajax +- Add website url to user profile +- Files API supports base64 encoded content (sponsored by O'Reilly Media) +- Added support for Go's repository retrieval (Bruno Albuquerque) + +## 6.4.3 + +- Don't use unicorn worker killer if PhusionPassenger is defined + +## 6.4.2 + +- Fixed wrong behaviour of script/upgrade.rb + +## 6.4.1 + +- Fixed bug with repository rename +- Fixed bug with project transfer + +## 6.4.0 + +- Added sorting to project issues page (Jason Blanchard) +- Assembla integration (Carlos Paramio) +- Fixed another 500 error with submodules +- UI: More compact issues page +- Minimal password length increased to 8 symbols +- Side-by-side diff view (Steven Thonus) +- Internal projects (Jason Hollingsworth) +- Allow removal of avatar (Drew Blessing) +- Project webhooks now support issues and merge request events +- Visiting project page while not logged in will redirect to sign-in instead of 404 (Jason Hollingsworth) +- Expire event cache on avatar creation/removal (Drew Blessing) +- Archiving old projects (Steven Thonus) +- Rails 4 +- Add time ago tooltips to show actual date/time +- UI: Fixed UI for admin system hooks +- Ruby script for easier GitLab upgrade +- Do not remove Merge requests if fork project was removed +- Improve sign-in/signup UX +- Add resend confirmation link to sign-in page +- Set noreply@HOSTNAME for reply_to field in all emails +- Show GitLab API version on Admin#dashboard +- API Cross-origin resource sharing +- Show READMe link at project home page +- Show repo size for projects in Admin area + +## 6.3.0 + +- API for adding gitlab-ci service +- Init script now waits for pids to appear after (re)starting before reporting status (Rovanion Luckey) +- Restyle project home page +- Grammar fixes +- Show branches list (which branches contains commit) on commit page (Andrew Kumanyaev) +- Security improvements +- Added support for GitLab CI 4.0 +- Fixed issue with 500 error when group did not exist +- Ability to leave project +- You can create file in repo using UI +- You can remove file from repo using UI +- API: dropped default_branch attribute from project during creation +- Project default_branch is not stored in db any more. It takes from repo now. +- Admin broadcast messages +- UI improvements +- Dont show last push widget if user removed this branch +- Fix 500 error for repos with newline in file name +- Extended html titles +- API: create/update/delete repo files +- Admin can transfer project to any namespace +- API: projects/all for admin users +- Fix recent branches order + +## 6.2.4 + +- Security: Cast API private_token to string (CVE-2013-4580) +- Security: Require gitlab-shell 1.7.8 (CVE-2013-4581, CVE-2013-4582, CVE-2013-4583) +- Fix for Git SSH access for LDAP users + +## 6.2.3 + +- Security: More protection against CVE-2013-4489 +- Security: Require gitlab-shell 1.7.4 (CVE-2013-4490, CVE-2013-4546) +- Fix sidekiq rake tasks + +## 6.2.2 + +- Security: Update gitlab_git (CVE-2013-4489) + +## 6.2.1 + +- Security: Fix issue with generated passwords for new users + +## 6.2.0 + +- Public project pages are now visible to everyone (files, issues, wik, etc.) + THIS MEANS YOUR ISSUES AND WIKI FOR PUBLIC PROJECTS ARE PUBLICLY VISIBLE AFTER THE UPGRADE +- Add group access to permissions page +- Require current password to change one +- Group owner or admin can remove other group owners +- Remove group transfer since we have multiple owners +- Respect authorization in Repository API +- Improve UI for Project#files page +- Add more security specs +- Added search for projects by name to api (Izaak Alpert) +- Make default user theme configurable (Izaak Alpert) +- Update logic for validates_merge_request for tree of MR (Andrew Kumanyaev) +- Rake tasks for webhooks management (Jonhnny Weslley) +- Extended User API to expose admin and can_create_group for user creation/updating (Boyan Tabakov) +- API: Remove group +- API: Remove project +- Avatar upload on profile page with a maximum of 100KB (Steven Thonus) +- Store the sessions in Redis instead of the cookie store +- Fixed relative links in markdown +- User must confirm their email if signup enabled +- User must confirm changed email + +## 6.1.0 + +- Project specific IDs for issues, mr, milestones + Above items will get a new id and for example all bookmarked issue urls will change. + Old issue urls are redirected to the new one if the issue id is too high for an internal id. +- Description field added to Merge Request +- API: Sudo api calls (Izaak Alpert) +- API: Group membership api (Izaak Alpert) +- Improved commit diff +- Improved large commit handling (Boyan Tabakov) +- Rewrite: Init script now less prone to errors and keeps better track of the service (Rovanion Luckey) +- Link issues, merge requests, and commits when they reference each other with GFM (Ash Wilson) +- Close issues automatically when pushing commits with a special message +- Improve user removal from admin area +- Invalidate events cache when project was moved +- Remove deprecated classes and rake tasks +- Add event filter for group and project show pages +- Add links to create branch/tag from project home page +- Add public-project? checkbox to new-project view +- Improved compare page. Added link to proceed into Merge Request +- Send an email to a user when they are added to group +- New landing page when you have 0 projects + +## 6.0.0 + +- Feature: Replace teams with group membership + We introduce group membership in 6.0 as a replacement for teams. + The old combination of groups and teams was confusing for a lot of people. + And when the members of a team where changed this wasn't reflected in the project permissions. + In GitLab 6.0 you will be able to add members to a group with a permission level for each member. + These group members will have access to the projects in that group. + Any changes to group members will immediately be reflected in the project permissions. + You can even have multiple owners for a group, greatly simplifying administration. +- Feature: Ability to have multiple owners for group +- Feature: Merge Requests between fork and project (Izaak Alpert) +- Feature: Generate fingerprint for ssh keys +- Feature: Ability to create and remove branches with UI +- Feature: Ability to create and remove git tags with UI +- Feature: Groups page in profile. You can leave group there +- API: Allow login with LDAP credentials +- Redesign: project settings navigation +- Redesign: snippets area +- Redesign: ssh keys page +- Redesign: buttons, blocks and other ui elements +- Add comment title to rss feed +- You can use arrows to navigate at tree view +- Add project filter on dashboard +- Cache project graph +- Drop support of root namespaces +- Default theme is classic now +- Cache result of methods like authorize_projects, project.team.members etc +- Remove $.ready events +- Fix onclick events being double binded +- Add notification level to group membership +- Move all project controllers/views under Projects:: module +- Move all profile controllers/views under Profiles:: module +- Apply user project limit only for personal projects +- Unicorn is default web server again +- Store satellites lock files inside satellites dir +- Disabled threadsafety mode in rails +- Fixed bug with loosing MR comments +- Improved MR comments logic +- Render readme file for projects in public area + +## 5.4.2 + +- Security: Cast API private_token to string (CVE-2013-4580) +- Security: Require gitlab-shell 1.7.8 (CVE-2013-4581, CVE-2013-4582, CVE-2013-4583) + +## 5.4.1 + +- Security: Fixes for CVE-2013-4489 +- Security: Require gitlab-shell 1.7.4 (CVE-2013-4490, CVE-2013-4546) + +## 5.4.0 + +- Ability to edit own comments +- Documentation improvements +- Improve dashboard projects page +- Fixed nav for empty repos +- GitLab Markdown help page +- Misspelling fixes +- Added support of unicorn and fog gems +- Added client list to API doc +- Fix PostgreSQL database restoration problem +- Increase snippet content column size +- allow project import via git:// url +- Show participants on issues, including mentions +- Notify mentioned users with email + +## 5.3.0 + +- Refactored services +- Campfire service added +- HipChat service added +- Fixed bug with LDAP + git over http +- Fixed bug with google analytics code being ignored +- Improve sign-in page if ldap enabled +- Respect newlines in wall messages +- Generate the Rails secret token on first run +- Rename repo feature +- Init.d: remove gitlab.socket on service start +- Api: added teams api +- Api: Prevent blob content being escaped +- Api: Smart deploy key add behaviour +- Api: projects/owned.json return user owned project +- Fix bug with team assignation on project from #4109 +- Advanced snippets: public/private, project/personal (Andrew Kulakov) +- Repository Graphs (Karlo Nicholas T. Soriano) +- Fix dashboard lost if comment on commit +- Update gitlab-grack. Fixes issue with --depth option +- Fix project events duplicate on project page +- Fix postgres error when displaying network graph. +- Fix dashboard event filter when navigate via turbolinks +- init.d: Ensure socket is removed before starting service +- Admin area: Style teams:index, group:show pages +- Own page for failed forking +- Scrum view for milestone + +## 5.2.0 + +- Turbolinks +- Git over http with ldap credentials +- Diff with better colors and some spacing on the corners +- Default values for project features +- Fixed huge_commit view +- Restyle project clone panel +- Move Gitlab::Git code to gitlab_git gem +- Move update docs in repo +- Requires gitlab-shell v1.4.0 +- Fixed submodules listing under file tab +- Fork feature (Angus MacArthur) +- git version check in gitlab:check +- Shared deploy keys feature +- Ability to generate default labels set for issues +- Improve gfm autocomplete (Harold Luo) +- Added support for Google Analytics +- Code search feature (Javier Castro) + +## 5.1.0 + +- You can login with email or username now +- Corrected project transfer rollback when repository cannot be moved +- Move both repo and wiki when project transfer requested +- Admin area: project editing was removed from admin namespace +- Access: admin user has now access to any project. +- Notification settings +- Gitlab::Git set of objects to abstract from grit library +- Replace Unicorn web server with Puma +- Backup/Restore refactored. Backup dump project wiki too now +- Restyled Issues list. Show milestone version in issue row +- Restyled Merge Request list +- Backup now dump/restore uploads +- Improved performance of dashboard (Andrew Kumanyaev) +- File history now tracks renames (Akzhan Abdulin) +- Drop wiki migration tools +- Drop sqlite migration tools +- project tagging +- Paginate users in API +- Restyled network graph (Hiroyuki Sato) + +## 5.0.1 + +- Fixed issue with gitlab-grit being overridden by grit + +## 5.0.0 + +- Replaced gitolite with gitlab-shell +- Removed gitolite-related libraries +- State machine added +- Setup gitlab as git user +- Internal API +- Show team tab for empty projects +- Import repository feature +- Updated rails +- Use lambda for scopes +- Redesign admin area -> users +- Redesign admin area -> user +- Secure link to file attachments +- Add validations for Group and Team names +- Restyle team page for project +- Update capybara, rspec-rails, poltergeist to recent versions +- Wiki on git using Gollum +- Added Solarized Dark theme for code review +- Don't show user emails in autocomplete lists, profile pages +- Added settings tab for group, team, project +- Replace user popup with icons in header +- Handle project moving with gitlab-shell +- Added select2-rails for selectboxes with ajax data load +- Fixed search field on projects page +- Added teams to search autocomplete +- Move groups and teams on dashboard sidebar to sub-tabs +- API: improved return codes and docs. (Felix Gilcher, Sebastian Ziebell) +- Redesign wall to be more like chat +- Snippets, Wall features are disabled by default for new projects + +## 4.2.0 + +- Teams +- User show page. Via /u/username +- Show help contents on pages for better navigation +- Async gitolite calls +- added satellites logs +- can_create_group, can_create_team booleans for User +- Process webhooks async +- GFM: Fix images escaped inside links +- Network graph improved +- Switchable branches for network graph +- API: Groups +- Fixed project download + +## 4.1.0 + +- Optional Sign-Up +- Discussions +- Satellites outside of tmp +- Line numbers for blame +- Project public mode +- Public area with unauthorized access +- Load dashboard events with ajax +- remember dashboard filter in cookies +- replace resque with sidekiq +- fix routing issues +- cleanup rake tasks +- fix backup/restore +- scss cleanup +- show preview for note images +- improved network-graph +- get rid of app/roles/ +- added new classes Team, Repository +- Reduce amount of gitolite calls +- Ability to add user in all group projects +- remove deprecated configs +- replaced Korolev font with open font +- restyled admin/dashboard page +- restyled admin/projects page + +## 4.0.0 + +- Remove project code and path from API. Use id instead +- Return valid cloneable url to repo for webhook +- Fixed backup issue +- Reorganized settings +- Fixed commits compare +- Refactored scss +- Improve status checks +- Validates presence of User#name +- Fixed postgres support +- Removed sqlite support +- Modified post-receive hook +- Milestones can be closed now +- Show comment events on dashboard +- Quick add team members via group#people page +- [API] expose created date for hooks and SSH keys +- [API] list, create issue notes +- [API] list, create snippet notes +- [API] list, create wall notes +- Remove project code - use path instead +- added username field to user +- rake task to fill usernames based on emails create namespaces for users +- STI Group < Namespace +- Project has namespace_id +- Projects with namespaces also namespaced in gitolite and stored in subdir +- Moving project to group will move it under group namespace +- Ability to move project from namespaces to another +- Fixes commit patches getting escaped (see #2036) +- Support diff and patch generation for commits and merge request +- MergeReqest doesn't generate a temporary file for the patch any more +- Update the UI to allow downloading Patch or Diff + +## 3.1.0 + +- Updated gems +- Services: Gitlab CI integration +- Events filter on dashboard +- Own namespace for redis/resque +- Optimized commit diff views +- add alphabetical order for projects admin page +- Improved web editor +- Commit stats page +- Documentation split and cleanup +- Link to commit authors everywhere +- Restyled milestones list +- added Milestone to Merge Request +- Restyled Top panel +- Refactored Satellite Code +- Added file line links +- moved from capybara-webkit to poltergeist + phantomjs + +## 3.0.3 + +- Fixed bug with issues list in Chrome +- New Feature: Import team from another project + +## 3.0.2 + +- Fixed gitlab:app:setup +- Fixed application error on empty project in admin area +- Restyled last push widget + +## 3.0.1 + +- Fixed git over http + +## 3.0.0 + +- Projects groups +- Web Editor +- Fixed bug with gitolite keys +- UI improved +- Increased performance of application +- Show user avatar in last commit when browsing Files +- Refactored Gitlab::Merge +- Use Font Awesome for icons +- Separate observing of Note and MergeRequests +- Milestone "All Issues" filter +- Fix issue close and reopen button text and styles +- Fix forward/back while browsing Tree hierarchy +- Show number of notes for commits and merge requests +- Added support pg from box and update installation doc +- Reject ssh keys that break gitolite +- [API] list one project hook +- [API] edit project hook +- [API] list project snippets +- [API] allow to authorize using private token in HTTP header +- [API] add user creation + +## 2.9.1 + +- Fixed resque custom config init + +## 2.9.0 + +- fixed inline notes bugs +- refactored rspecs +- refactored gitolite backend +- added factory_girl +- restyled projects list on dashboard +- ssh keys validation to prevent gitolite crash +- send notifications if changed permission in project +- scss refactoring. gitlab_bootstrap/ dir +- fix git push http body bigger than 112k problem +- list of labels page under issues tab +- API for milestones, keys +- restyled buttons +- OAuth +- Comment order changed + +## 2.8.1 + +- ability to disable gravatars +- improved MR diff logic +- ssh key help page + +## 2.8.0 + +- Gitlab Flavored Markdown +- Bulk issues update +- Issues API +- Cucumber coverage increased +- Post-receive files fixed +- UI improved +- Application cleanup +- more cucumber +- capybara-webkit + headless + +## 2.7.0 + +- Issue Labels +- Inline diff +- Git HTTP +- API +- UI improved +- System hooks +- UI improved +- Dashboard events endless scroll +- Source performance increased + +## 2.6.0 + +- UI polished +- Improved network graph + keyboard nav +- Handle huge commits +- Last Push widget +- Bugfix +- Better performance +- Email in resque +- Increased test coverage +- Ability to remove branch with MR accept +- a lot of code refactored + +## 2.5.0 + +- UI polished +- Git blame for file +- Bugfix +- Email in resque +- Better test coverage + +## 2.4.0 + +- Admin area stats page +- Ability to block user +- Simplified dashboard area +- Improved admin area +- Bootstrap 2.0 +- Responsive layout +- Big commits handling +- Performance improved +- Milestones + +## 2.3.1 + +- Issues pagination +- ssl fixes +- Merge Request pagination + +## 2.3.0 + +- Dashboard r1 +- Search r1 +- Project page +- Close merge request on push +- Persist MR diff after merge +- mysql support +- Documentation + +## 2.2.0 + +- We’ve added support of LDAP auth +- Improved permission logic (4 roles system) +- Protected branches (now only masters can push to protected branches) +- Usability improved +- twitter bootstrap integrated +- compare view between commits +- wiki feature +- now you can enable/disable issues, wiki, wall features per project +- security fixes +- improved code browsing (ajax branch switch etc) +- improved per-line commenting +- git submodules displayed +- moved to rails 3.2 +- help section improved + +## 2.1.0 + +- Project tab r1 +- List branches/tags +- per line comments +- mass user import + +## 2.0.0 + +- gitolite as main git host system +- merge requests +- project/repo access +- link to commit/issue feed +- design tab +- improved email notifications +- restyled dashboard +- bugfix + +## 1.2.2 + +- common config file gitlab.yml +- issues restyle +- snippets restyle +- clickable news feed header on dashboard +- bugfix + +## 1.2.1 + +- bugfix + +## 1.2.0 + +- new design +- user dashboard +- network graph +- markdown support for comments +- encoding issues +- wall like twitter timeline + +## 1.1.0 + +- project dashboard +- wall redesigned +- feature: code snippets +- fixed horizontal scroll on file preview +- fixed app crash if commit message has invalid chars +- bugfix & code cleaning + +## 1.0.2 + +- fixed bug with empty project +- added adv validation for project path & code +- feature: issues can be sortable +- bugfix +- username displayed on top panel + +## 1.0.1 + +- fixed: with invalid source code for commit +- fixed: lose branch/tag selection when use tree navigation +- when history clicked - display path +- bug fix & code cleaning + +## 1.0.0 + +- bug fix +- projects preview mode + +## 0.9.6 + +- css fix +- new repo empty tree until restart server - fixed + +## 0.9.4 + +- security improved +- authorization improved +- html escaping +- bug fix +- increased test coverage +- design improvements + +## 0.9.1 + +- increased test coverage +- design improvements +- new issue email notification +- updated app name +- issue redesigned +- issue can be edit + +## 0.8.0 + +- syntax highlight for main file types +- redesign +- stability +- security fixes +- increased test coverage +- email notification diff --git a/.vagrant_enabled b/changelogs/unreleased/.gitkeep index e69de29bb2d..e69de29bb2d 100644 --- a/.vagrant_enabled +++ b/changelogs/unreleased/.gitkeep diff --git a/config/application.rb b/config/application.rb index 06ebb14a5fe..962ffe0708d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -50,6 +50,7 @@ module Gitlab # - Build variables (:variables) # - GitLab Pages SSL cert/key info (:certificate, :encrypted_key) # - Webhook URLs (:hook) + # - GitLab-shell secret token (:secret_token) # - Sentry DSN (:sentry_dsn) # - Deploy keys (:key) config.filter_parameters += %i( @@ -62,6 +63,7 @@ module Gitlab password password_confirmation private_token + secret_token sentry_dsn variables ) @@ -85,6 +87,11 @@ module Gitlab config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "profile/profile_bundle.js" + config.assets.precompile << "diff_notes/diff_notes_bundle.js" + config.assets.precompile << "boards/boards_bundle.js" + config.assets.precompile << "boards/test_utils/simulate_drag.js" + config.assets.precompile << "blob_edit/blob_edit_bundle.js" + config.assets.precompile << "snippet/snippet_bundle.js" config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/*.js" config.assets.precompile << "u2f.js" @@ -94,22 +101,38 @@ module Gitlab config.action_view.sanitized_allowed_protocols = %w(smb) - config.middleware.use Rack::Attack + config.middleware.insert_before Warden::Manager, Rack::Attack # Allow access to GitLab API from other domains - config.middleware.use Rack::Cors do + config.middleware.insert_before Warden::Manager, Rack::Cors do + allow do + origins Gitlab.config.gitlab.url + resource '/api/*', + credentials: true, + headers: :any, + methods: :any, + expose: ['Link'] + end + + # Cross-origin requests must not have the session cookie available allow do origins '*' resource '/api/*', + credentials: false, headers: :any, methods: :any, expose: ['Link'] end end - redis_config_hash = Gitlab::Redis.redis_store_options + # Use Redis caching across all environments + redis_config_hash = Gitlab::Redis.params redis_config_hash[:namespace] = Gitlab::Redis::CACHE_NAMESPACE redis_config_hash[:expires_in] = 2.weeks # Cache should not grow forever + if Sidekiq.server? # threaded context + redis_config_hash[:pool_size] = Sidekiq.options[:concurrency] + 5 + redis_config_hash[:pool_timeout] = 1 + end config.cache_store = :redis_store, redis_config_hash config.active_record.raise_in_transactional_callbacks = true diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 1470a6e2550..114ceac8e1f 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -70,6 +70,7 @@ production: &base email_from: example@example.com email_display_name: GitLab email_reply_to: noreply@example.com + email_subject_suffix: '' # Email server smtp settings are in config/initializers/smtp_settings.rb.sample @@ -111,7 +112,7 @@ production: &base ## Reply by email # Allow users to comment on issues and merge requests by replying to notification emails. - # For documentation on how to set this up, see http://doc.gitlab.com/ce/incoming_email/README.html + # For documentation on how to set this up, see http://doc.gitlab.com/ce/administration/reply_by_email.html incoming_email: enabled: false diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 49130f37b31..efe0ac9c965 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -186,6 +186,7 @@ Settings.gitlab['email_enabled'] ||= true if Settings.gitlab['email_enabled'].ni Settings.gitlab['email_from'] ||= ENV['GITLAB_EMAIL_FROM'] || "gitlab@#{Settings.gitlab.host}" Settings.gitlab['email_display_name'] ||= ENV['GITLAB_EMAIL_DISPLAY_NAME'] || 'GitLab' Settings.gitlab['email_reply_to'] ||= ENV['GITLAB_EMAIL_REPLY_TO'] || "noreply@#{Settings.gitlab.host}" +Settings.gitlab['email_subject_suffix'] ||= ENV['GITLAB_EMAIL_SUBJECT_SUFFIX'] || "" Settings.gitlab['base_url'] ||= Settings.send(:build_base_gitlab_url) Settings.gitlab['url'] ||= Settings.send(:build_gitlab_url) Settings.gitlab['user'] ||= 'git' @@ -212,7 +213,7 @@ Settings.gitlab.default_projects_features['builds'] = true if Settin Settings.gitlab.default_projects_features['container_registry'] = true if Settings.gitlab.default_projects_features['container_registry'].nil? Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE) Settings.gitlab['domain_whitelist'] ||= [] -Settings.gitlab['import_sources'] ||= %w[github bitbucket gitlab gitorious google_code fogbugz git gitlab_project] +Settings.gitlab['import_sources'] ||= %w[github bitbucket gitlab google_code fogbugz git gitlab_project] Settings.gitlab['trusted_proxies'] ||= [] # @@ -287,12 +288,25 @@ Settings.cron_jobs['admin_email_worker']['job_class'] = 'AdminEmailWorker' Settings.cron_jobs['repository_archive_cache_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_archive_cache_worker']['cron'] ||= '0 * * * *' Settings.cron_jobs['repository_archive_cache_worker']['job_class'] = 'RepositoryArchiveCacheWorker' -Settings.cron_jobs['gitlab_remove_project_export_worker'] ||= Settingslogic.new({}) -Settings.cron_jobs['gitlab_remove_project_export_worker']['cron'] ||= '0 * * * *' -Settings.cron_jobs['gitlab_remove_project_export_worker']['job_class'] = 'GitlabRemoveProjectExportWorker' +Settings.cron_jobs['import_export_project_cleanup_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['import_export_project_cleanup_worker']['cron'] ||= '0 * * * *' +Settings.cron_jobs['import_export_project_cleanup_worker']['job_class'] = 'ImportExportProjectCleanupWorker' Settings.cron_jobs['requests_profiles_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['requests_profiles_worker']['cron'] ||= '0 0 * * *' Settings.cron_jobs['requests_profiles_worker']['job_class'] = 'RequestsProfilesWorker' +Settings.cron_jobs['remove_expired_members_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['remove_expired_members_worker']['cron'] ||= '10 0 * * *' +Settings.cron_jobs['remove_expired_members_worker']['job_class'] = 'RemoveExpiredMembersWorker' +Settings.cron_jobs['remove_expired_group_links_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['remove_expired_group_links_worker']['cron'] ||= '10 0 * * *' +Settings.cron_jobs['remove_expired_group_links_worker']['job_class'] = 'RemoveExpiredGroupLinksWorker' +Settings.cron_jobs['prune_old_events_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['prune_old_events_worker']['cron'] ||= '* */6 * * *' +Settings.cron_jobs['prune_old_events_worker']['job_class'] = 'PruneOldEventsWorker' + +Settings.cron_jobs['trending_projects_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['trending_projects_worker']['cron'] = '0 1 * * *' +Settings.cron_jobs['trending_projects_worker']['job_class'] = 'TrendingProjectsWorker' # # GitLab Shell diff --git a/config/initializers/5_backend.rb b/config/initializers/5_backend.rb index e026151a032..ed88c8ee1b8 100644 --- a/config/initializers/5_backend.rb +++ b/config/initializers/5_backend.rb @@ -1,6 +1,3 @@ -# GIT over HTTP -require_dependency Rails.root.join('lib/gitlab/backend/grack_auth') - # GIT over SSH require_dependency Rails.root.join('lib/gitlab/backend/shell') diff --git a/config/initializers/7_redis.rb b/config/initializers/7_redis.rb new file mode 100644 index 00000000000..ae2ca258df1 --- /dev/null +++ b/config/initializers/7_redis.rb @@ -0,0 +1,3 @@ +# Make sure we initialize a Redis connection pool before Sidekiq starts +# multi-threaded execution. +Gitlab::Redis.with { nil } diff --git a/config/initializers/ar5_batching.rb b/config/initializers/ar5_batching.rb new file mode 100644 index 00000000000..35e8b3808e2 --- /dev/null +++ b/config/initializers/ar5_batching.rb @@ -0,0 +1,41 @@ +# Port ActiveRecord::Relation#in_batches from ActiveRecord 5. +# https://github.com/rails/rails/blob/ac027338e4a165273607dccee49a3d38bc836794/activerecord/lib/active_record/relation/batches.rb#L184 +# TODO: this can be removed once we're using AR5. +raise "Vendored ActiveRecord 5 code! Delete #{__FILE__}!" if ActiveRecord::VERSION::MAJOR >= 5 + +module ActiveRecord + module Batches + # Differences from upstream: enumerator support was removed, and custom + # order/limit clauses are ignored without a warning. + def in_batches(of: 1000, start: nil, finish: nil, load: false) + raise "Must provide a block" unless block_given? + + relation = self.reorder(batch_order).limit(of) + relation = relation.where(arel_table[primary_key].gteq(start)) if start + relation = relation.where(arel_table[primary_key].lteq(finish)) if finish + batch_relation = relation + + loop do + if load + records = batch_relation.records + ids = records.map(&:id) + yielded_relation = self.where(primary_key => ids) + yielded_relation.load_records(records) + else + ids = batch_relation.pluck(primary_key) + yielded_relation = self.where(primary_key => ids) + end + + break if ids.empty? + + primary_key_offset = ids.last + raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset + + yield yielded_relation + + break if ids.length < of + batch_relation = relation.where(arel_table[primary_key].gt(primary_key_offset)) + end + end + end +end diff --git a/config/initializers/ar_monkey_patch.rb b/config/initializers/ar_monkey_patch.rb new file mode 100644 index 00000000000..0da584626ee --- /dev/null +++ b/config/initializers/ar_monkey_patch.rb @@ -0,0 +1,57 @@ +# rubocop:disable Lint/RescueException + +# This patch fixes https://github.com/rails/rails/issues/26024 +# TODO: Remove it when it's no longer necessary + +module ActiveRecord + module Locking + module Optimistic + # We overwrite this method because we don't want to have default value + # for newly created records + def _create_record(attribute_names = self.attribute_names, *) # :nodoc: + super + end + + def _update_record(attribute_names = self.attribute_names) #:nodoc: + return super unless locking_enabled? + return 0 if attribute_names.empty? + + lock_col = self.class.locking_column + + previous_lock_value = send(lock_col).to_i + + # This line is added as a patch + previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0 + + increment_lock + + attribute_names += [lock_col] + attribute_names.uniq! + + begin + relation = self.class.unscoped + + affected_rows = relation.where( + self.class.primary_key => id, + lock_col => previous_lock_value, + ).update_all( + attributes_for_update(attribute_names).map do |name| + [name, _read_attribute(name)] + end.to_h + ) + + unless affected_rows == 1 + raise ActiveRecord::StaleObjectError.new(self, "update") + end + + affected_rows + + # If something went wrong, revert the version. + rescue Exception + send(lock_col + '=', previous_lock_value) + raise + end + end + end + end +end diff --git a/config/initializers/ar_speed_up_migration_checking.rb b/config/initializers/ar_speed_up_migration_checking.rb new file mode 100644 index 00000000000..1fe5defc01d --- /dev/null +++ b/config/initializers/ar_speed_up_migration_checking.rb @@ -0,0 +1,18 @@ +if Rails.env.test? + require 'active_record/migration' + + module ActiveRecord + class Migrator + class << self + alias_method :migrations_unmemoized, :migrations + + # This method is called a large number of times per rspec example, and + # it reads + parses `db/migrate/*` each time. Memoizing it can save 0.5 + # seconds per spec. + def migrations(paths) + @migrations ||= migrations_unmemoized(paths) + end + end + end + end +end diff --git a/config/initializers/attr_encrypted_no_db_connection.rb b/config/initializers/attr_encrypted_no_db_connection.rb index c668864089b..e007666b852 100644 --- a/config/initializers/attr_encrypted_no_db_connection.rb +++ b/config/initializers/attr_encrypted_no_db_connection.rb @@ -1,20 +1,21 @@ module AttrEncrypted module Adapters module ActiveRecord - def attribute_instance_methods_as_symbols_with_no_db_connection - # Use with_connection so the connection doesn't stay pinned to the thread. - connected = ::ActiveRecord::Base.connection_pool.with_connection(&:active?) rescue false - - if connected - # Call version from AttrEncrypted::Adapters::ActiveRecord - attribute_instance_methods_as_symbols_without_no_db_connection - else - # Call version from AttrEncrypted, i.e., `super` with regards to AttrEncrypted::Adapters::ActiveRecord - AttrEncrypted.instance_method(:attribute_instance_methods_as_symbols).bind(self).call + module DBConnectionQuerier + def attribute_instance_methods_as_symbols + # Use with_connection so the connection doesn't stay pinned to the thread. + connected = ::ActiveRecord::Base.connection_pool.with_connection(&:active?) rescue false + + if connected + # Call version from AttrEncrypted::Adapters::ActiveRecord + super + else + # Call version from AttrEncrypted, i.e., `super` with regards to AttrEncrypted::Adapters::ActiveRecord + AttrEncrypted.instance_method(:attribute_instance_methods_as_symbols).bind(self).call + end end end - - alias_method_chain :attribute_instance_methods_as_symbols, :no_db_connection + prepend DBConnectionQuerier end end end diff --git a/config/initializers/connection_fix.rb b/config/initializers/connection_fix.rb index d831a1838ed..d0b1444f607 100644 --- a/config/initializers/connection_fix.rb +++ b/config/initializers/connection_fix.rb @@ -20,7 +20,7 @@ if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) execute_without_retry(*args) rescue ActiveRecord::StatementInvalid => e if e.message =~ /server has gone away/i - warn "Server timed out, retrying" + warn "Lost connection to MySQL server during query" reconnect! retry else diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 73977341b73..a0a8f88584c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -100,6 +100,9 @@ Devise.setup do |config| # secure: true in order to force SSL only cookies. # config.cookie_options = {} + # Send a notification email when the user's password is changed + config.send_password_change_notification = true + # ==> Configuration for :validatable # Range for password length. Default is 6..128. config.password_length = 8..128 diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 618dba74151..fc4b0a72add 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -12,7 +12,8 @@ Doorkeeper.configure do end resource_owner_from_credentials do |routes| - Gitlab::Auth.find_with_user_password(params[:username], params[:password]) + user = Gitlab::Auth.find_with_user_password(params[:username], params[:password]) + user unless user.try(:two_factor_enabled?) end # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. diff --git a/config/initializers/gitlab_shell_secret_token.rb b/config/initializers/gitlab_shell_secret_token.rb index 7454c33c9dd..529dcdd4644 100644 --- a/config/initializers/gitlab_shell_secret_token.rb +++ b/config/initializers/gitlab_shell_secret_token.rb @@ -1 +1 @@ -Gitlab::Shell.new.generate_and_link_secret_token +Gitlab::Shell.ensure_secret_token! diff --git a/config/initializers/gitlab_workhorse_secret.rb b/config/initializers/gitlab_workhorse_secret.rb new file mode 100644 index 00000000000..ed54dc11098 --- /dev/null +++ b/config/initializers/gitlab_workhorse_secret.rb @@ -0,0 +1,8 @@ +begin + Gitlab::Workhorse.secret +rescue + Gitlab::Workhorse.write_secret +end + +# Try a second time. If it does not work this will raise. +Gitlab::Workhorse.secret diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb index cc8208db3c1..be22085b0df 100644 --- a/config/initializers/metrics.rb +++ b/config/initializers/metrics.rb @@ -68,7 +68,8 @@ if Gitlab::Metrics.enabled? ['app', 'mailers', 'emails'] => ['app', 'mailers'], ['app', 'services', '**'] => ['app', 'services'], ['lib', 'gitlab', 'diff'] => ['lib'], - ['lib', 'gitlab', 'email', 'message'] => ['lib'] + ['lib', 'gitlab', 'email', 'message'] => ['lib'], + ['lib', 'gitlab', 'checks'] => ['lib'] } paths_to_instrument.each do |(path, prefix)| @@ -148,6 +149,9 @@ if Gitlab::Metrics.enabled? config.instrument_methods(Gitlab::Highlight) config.instrument_instance_methods(Gitlab::Highlight) + + # This is a Rails scope so we have to instrument it manually. + config.instrument_method(Project, :visible_to_user) end GC::Profiler.enable diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 3e553120205..5e3e4c966cb 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -12,3 +12,6 @@ Mime::Type.register_alias "text/html", :md Mime::Type.register "video/mp4", :mp4, [], [:m4v, :mov] Mime::Type.register "video/webm", :webm Mime::Type.register "video/ogg", :ogv + +Mime::Type.unregister :json +Mime::Type.register 'application/json', :json, %w(application/vnd.git-lfs+json application/json) diff --git a/config/initializers/postgresql_limit_fix.rb b/config/initializers/postgresql_limit_fix.rb index 0cb3aaf4d24..4224d857e8a 100644 --- a/config/initializers/postgresql_limit_fix.rb +++ b/config/initializers/postgresql_limit_fix.rb @@ -1,5 +1,19 @@ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter + module LimitFilter + def add_column(table_name, column_name, type, options = {}) + options.delete(:limit) if type == :text + super(table_name, column_name, type, options) + end + + def change_column(table_name, column_name, type, options = {}) + options.delete(:limit) if type == :text + super(table_name, column_name, type, options) + end + end + + prepend ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::LimitFilter + class TableDefinition def text(*args) options = args.extract_options! @@ -9,18 +23,5 @@ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) column_names.each { |name| column(name, type, options) } end end - - def add_column_with_limit_filter(table_name, column_name, type, options = {}) - options.delete(:limit) if type == :text - add_column_without_limit_filter(table_name, column_name, type, options) - end - - def change_column_with_limit_filter(table_name, column_name, type, options = {}) - options.delete(:limit) if type == :text - change_column_without_limit_filter(table_name, column_name, type, options) - end - - alias_method_chain :add_column, :limit_filter - alias_method_chain :change_column, :limit_filter end end diff --git a/config/initializers/secret_token.rb b/config/initializers/secret_token.rb index dae3a4a9a93..291fa6c0abc 100644 --- a/config/initializers/secret_token.rb +++ b/config/initializers/secret_token.rb @@ -2,49 +2,86 @@ require 'securerandom' -# Your secret key for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. - -def find_secure_token - token_file = Rails.root.join('.secret') - if ENV.key?('SECRET_KEY_BASE') - ENV['SECRET_KEY_BASE'] - elsif File.exist? token_file - # Use the existing token. - File.read(token_file).chomp - else - # Generate a new token of 64 random hexadecimal characters and store it in token_file. - token = SecureRandom.hex(64) - File.write(token_file, token) - token +# Transition material in .secret to the secret_key_base key in config/secrets.yml. +# Historically, ENV['SECRET_KEY_BASE'] takes precedence over .secret, so we maintain that +# behavior. +# +# It also used to be the case that the key material in ENV['SECRET_KEY_BASE'] or .secret +# was used to encrypt OTP (two-factor authentication) data so if present, we copy that key +# material into config/secrets.yml under otp_key_base. +# +# Finally, if we have successfully migrated all secrets to config/secrets.yml, delete the +# .secret file to avoid confusion. +# +def create_tokens + secret_file = Rails.root.join('.secret') + file_secret_key = File.read(secret_file).chomp if File.exist?(secret_file) + env_secret_key = ENV['SECRET_KEY_BASE'] + + # Ensure environment variable always overrides secrets.yml. + Rails.application.secrets.secret_key_base = env_secret_key if env_secret_key.present? + + defaults = { + secret_key_base: file_secret_key || generate_new_secure_token, + otp_key_base: env_secret_key || file_secret_key || generate_new_secure_token, + db_key_base: generate_new_secure_token + } + + missing_secrets = set_missing_keys(defaults) + write_secrets_yml(missing_secrets) unless missing_secrets.empty? + + begin + File.delete(secret_file) if file_secret_key + rescue => e + warn "Error deleting useless .secret file: #{e}" end end -Rails.application.config.secret_token = find_secure_token -Rails.application.config.secret_key_base = find_secure_token - -# CI def generate_new_secure_token SecureRandom.hex(64) end -if Rails.application.secrets.db_key_base.blank? - warn "Missing `db_key_base` for '#{Rails.env}' environment. The secrets will be generated and stored in `config/secrets.yml`" +def warn_missing_secret(secret) + warn "Missing Rails.application.secrets.#{secret} for #{Rails.env} environment. The secret will be generated and stored in config/secrets.yml." +end - all_secrets = YAML.load_file('config/secrets.yml') if File.exist?('config/secrets.yml') - all_secrets ||= {} +def set_missing_keys(defaults) + defaults.stringify_keys.each_with_object({}) do |(key, default), missing| + if Rails.application.secrets[key].blank? + warn_missing_secret(key) - # generate secrets - env_secrets = all_secrets[Rails.env.to_s] || {} - env_secrets['db_key_base'] ||= generate_new_secure_token - all_secrets[Rails.env.to_s] = env_secrets + missing[key] = Rails.application.secrets[key] = default + end + end +end + +def write_secrets_yml(missing_secrets) + secrets_yml = Rails.root.join('config/secrets.yml') + rails_env = Rails.env.to_s + secrets = YAML.load_file(secrets_yml) if File.exist?(secrets_yml) + secrets ||= {} + secrets[rails_env] ||= {} + + secrets[rails_env].merge!(missing_secrets) do |key, old, new| + # Previously, it was possible this was set to the literal contents of an Erb + # expression that evaluated to an empty value. We don't want to support that + # specifically, just ensure we don't break things further. + # + if old.present? + warn <<EOM +Rails.application.secrets.#{key} was blank, but the literal value in config/secrets.yml was: + #{old} - # save secrets - File.open('config/secrets.yml', 'w', 0600) do |file| - file.write(YAML.dump(all_secrets)) +This probably isn't the expected value for this secret. To keep using a literal Erb string in config/secrets.yml, replace `<%` with `<%%`. +EOM + + exit 1 # rubocop:disable Rails/Exit + end + + new end - Rails.application.secrets.db_key_base = env_secrets['db_key_base'] + File.write(secrets_yml, YAML.dump(secrets), mode: 'w', perm: 0600) end + +create_tokens diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 74fef7cadfe..4f30d1265c8 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -18,6 +18,9 @@ if Rails.env.production? # Sanitize fields based on those sanitized from Rails. config.sanitize_fields = Rails.application.config.filter_parameters.map(&:to_s) + # Sanitize authentication headers + config.sanitize_http_headers = %w[Authorization Private-Token] + config.tags = { program: Gitlab::Sentry.program_context } end end end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 0d9d87bac00..70be2617cab 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -13,9 +13,9 @@ end if Rails.env.test? Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session" else - redis_config = Gitlab::Redis.redis_store_options + redis_config = Gitlab::Redis.params redis_config[:namespace] = Gitlab::Redis::SESSION_NAMESPACE - + Gitlab::Application.config.session_store( :redis_store, # Using the cookie_store would enable session replay attacks. servers: redis_config, diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index cf49ec2194c..f7e714cd6bc 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,8 +1,9 @@ +# Custom Redis configuration +redis_config_hash = Gitlab::Redis.params +redis_config_hash[:namespace] = Gitlab::Redis::SIDEKIQ_NAMESPACE + Sidekiq.configure_server do |config| - config.redis = { - url: Gitlab::Redis.url, - namespace: Gitlab::Redis::SIDEKIQ_NAMESPACE - } + config.redis = redis_config_hash config.server_middleware do |chain| chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if ENV['SIDEKIQ_LOG_ARGUMENTS'] @@ -39,8 +40,5 @@ Sidekiq.configure_server do |config| end Sidekiq.configure_client do |config| - config.redis = { - url: Gitlab::Redis.url, - namespace: Gitlab::Redis::SIDEKIQ_NAMESPACE - } + config.redis = redis_config_hash end diff --git a/config/mail_room.yml b/config/mail_room.yml index 7cab24b295e..c639f8260aa 100644 --- a/config/mail_room.yml +++ b/config/mail_room.yml @@ -1,47 +1,36 @@ +# If you change this file in a Merge Request, please also create +# a Merge Request on https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests +# :mailboxes: -<% -require "yaml" -require "json" -require_relative "lib/gitlab/redis" unless defined?(Gitlab::Redis) + <% + require_relative "lib/gitlab/mail_room" unless defined?(Gitlab::MailRoom) + config = Gitlab::MailRoom.config -rails_env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development" - -config_file = ENV["MAIL_ROOM_GITLAB_CONFIG_FILE"] || "config/gitlab.yml" -if File.exists?(config_file) - all_config = YAML.load_file(config_file)[rails_env] - - config = all_config["incoming_email"] || {} - config['enabled'] = false if config['enabled'].nil? - config['port'] = 143 if config['port'].nil? - config['ssl'] = false if config['ssl'].nil? - config['start_tls'] = false if config['start_tls'].nil? - config['mailbox'] = "inbox" if config['mailbox'].nil? - - if config['enabled'] && config['address'] - redis_url = Gitlab::Redis.new(rails_env).url - %> + if Gitlab::MailRoom.enabled? + %> - - :host: <%= config['host'].to_json %> - :port: <%= config['port'].to_json %> - :ssl: <%= config['ssl'].to_json %> - :start_tls: <%= config['start_tls'].to_json %> - :email: <%= config['user'].to_json %> - :password: <%= config['password'].to_json %> + :host: <%= config[:host].to_json %> + :port: <%= config[:port].to_json %> + :ssl: <%= config[:ssl].to_json %> + :start_tls: <%= config[:start_tls].to_json %> + :email: <%= config[:user].to_json %> + :password: <%= config[:password].to_json %> + :idle_timeout: 60 - :name: <%= config['mailbox'].to_json %> + :name: <%= config[:mailbox].to_json %> :delete_after_delivery: true :delivery_method: sidekiq :delivery_options: - :redis_url: <%= redis_url.to_json %> - :namespace: resque:gitlab + :redis_url: <%= config[:redis_url].to_json %> + :namespace: <%= Gitlab::Redis::SIDEKIQ_NAMESPACE %> :queue: incoming_email :worker: EmailReceiverWorker :arbitration_method: redis :arbitration_options: - :redis_url: <%= redis_url.to_json %> - :namespace: mail_room:gitlab + :redis_url: <%= config[:redis_url].to_json %> + :namespace: <%= Gitlab::Redis::MAILROOM_NAMESPACE %> + <% end %> -<% end %> diff --git a/config/resque.yml.example b/config/resque.yml.example index d98f43f71b2..0c19d8bc1d3 100644 --- a/config/resque.yml.example +++ b/config/resque.yml.example @@ -1,6 +1,34 @@ # If you change this file in a Merge Request, please also create # a Merge Request on https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests # -development: redis://localhost:6379 -test: redis://localhost:6379 -production: unix:/var/run/redis/redis.sock +development: + url: redis://localhost:6379 + # sentinels: + # - + # host: localhost + # port: 26380 # point to sentinel, not to redis port + # - + # host: slave2 + # port: 26381 # point to sentinel, not to redis port +test: + url: redis://localhost:6379 +production: + # Redis (single instance) + url: unix:/var/run/redis/redis.sock + ## + # Redis + Sentinel (for HA) + # + # Please read instructions carefully before using it as you may lose data: + # http://redis.io/topics/sentinel + # + # You must specify a list of a few sentinels that will handle client connection + # please read here for more information: https://docs.gitlab.com/ce/administration/high_availability/redis.html + ## + # url: redis://master:6379 + # sentinels: + # - + # host: slave1 + # port: 26379 # point to sentinel, not to redis port + # - + # host: slave2 + # port: 26379 # point to sentinel, not to redis port diff --git a/config/routes.rb b/config/routes.rb index 2f5f32d9e30..83c3a42c19f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,55 +2,26 @@ require 'sidekiq/web' require 'sidekiq/cron/web' require 'api/api' -Rails.application.routes.draw do - if Gitlab::Sherlock.enabled? - namespace :sherlock do - resources :transactions, only: [:index, :show] do - resources :queries, only: [:show] - resources :file_samples, only: [:show] - - collection do - delete :destroy_all - end - end - end - end - - if Rails.env.development? - # Make the built-in Rails routes available in development, otherwise they'd - # get swallowed by the `namespace/project` route matcher below. - # - # See https://git.io/va79N - get '/rails/mailers' => 'rails/mailers#index' - get '/rails/mailers/:path' => 'rails/mailers#preview' - get '/rails/info/properties' => 'rails/info#properties' - get '/rails/info/routes' => 'rails/info#routes' - get '/rails/info' => 'rails/info#index' - - mount LetterOpenerWeb::Engine, at: '/rails/letter_opener' +class ActionDispatch::Routing::Mapper + def draw(routes_name) + instance_eval(File.read(Rails.root.join("config/routes/#{routes_name}.rb"))) end +end +Rails.application.routes.draw do concern :access_requestable do post :request_access, on: :collection post :approve_access_request, on: :member end - namespace :ci do - # CI API - Ci::API::API.logger Rails.logger - mount Ci::API::API => '/api' - - resource :lint, only: [:show, :create] - - resources :projects, only: [:index, :show] do - member do - get :status, to: 'projects#badge' - end - end - - root to: 'projects#index' + concern :awardable do + post :toggle_award_emoji, on: :member end + draw :sherlock + draw :development + draw :ci + use_doorkeeper do controllers applications: 'oauth/applications', authorized_applications: 'oauth/authorized_applications', @@ -72,43 +43,18 @@ Rails.application.routes.draw do # JSON Web Token get 'jwt/auth' => 'jwt#auth' - # API - API::API.logger Rails.logger - mount API::API => '/api' - - constraint = lambda { |request| request.env['warden'].authenticate? and request.env['warden'].user.admin? } - constraints constraint do - mount Sidekiq::Web, at: '/admin/sidekiq', as: :sidekiq - end - # Health check get 'health_check(/:checks)' => 'health_check#index', as: :health_check - # Enable Grack support (for LFS only) - mount Grack::AuthSpawner, at: '/', constraints: lambda { |request| /[-\/\w\.]+\.git\/(info\/lfs|gitlab-lfs)/.match(request.path_info) }, via: [:get, :post, :put] + # Koding route + get 'koding' => 'koding#index' - # Help - get 'help' => 'help#index' - get 'help/shortcuts' => 'help#shortcuts' - get 'help/ui' => 'help#ui' - get 'help/*path' => 'help#show', as: :help_page + draw :api + draw :sidekiq + draw :help + draw :snippets - # - # Global snippets - # - resources :snippets do - member do - get 'raw' - end - end - - get '/s/:username', to: redirect('/u/%{username}/snippets'), - constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ } - - # # Invites - # - resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do member do post :accept @@ -122,751 +68,24 @@ Rails.application.routes.draw do end end - # # Spam reports - # resources :abuse_reports, only: [:new, :create] - # # Notification settings - # resources :notification_settings, only: [:create, :update] - # - # Import - # - namespace :import do - resource :github, only: [:create, :new], controller: :github do - post :personal_access_token - get :status - get :callback - get :jobs - end - - resource :gitlab, only: [:create], controller: :gitlab do - get :status - get :callback - get :jobs - end - - resource :bitbucket, only: [:create], controller: :bitbucket do - get :status - get :callback - get :jobs - end - - resource :gitorious, only: [:create, :new], controller: :gitorious do - get :status - get :callback - get :jobs - end - - resource :google_code, only: [:create, :new], controller: :google_code do - get :status - post :callback - get :jobs - - get :new_user_map, path: :user_map - post :create_user_map, path: :user_map - end - - resource :fogbugz, only: [:create, :new], controller: :fogbugz do - get :status - post :callback - get :jobs - - get :new_user_map, path: :user_map - post :create_user_map, path: :user_map - end - - resource :gitlab_project, only: [:create, :new] do - post :create - end - end - - # - # Uploads - # - - scope path: :uploads do - # Note attachments and User/Group/Project avatars - get ":model/:mounted_as/:id/:filename", - to: "uploads#show", - constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ } - - # Appearance - get ":model/:mounted_as/:id/:filename", - to: "uploads#show", - constraints: { model: /appearance/, mounted_as: /logo|header_logo/, filename: /.+/ } - - # Project markdown uploads - get ":namespace_id/:project_id/:secret/:filename", - to: "projects/uploads#show", - constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } - end - - # Redirect old note attachments path to new uploads path. - get "files/note/:id/:filename", - to: redirect("uploads/note/attachment/%{id}/%{filename}"), - constraints: { filename: /[^\/]+/ } - - # - # Explore area - # - namespace :explore do - resources :projects, only: [:index] do - collection do - get :trending - get :starred - end - end - - resources :groups, only: [:index] - resources :snippets, only: [:index] - root to: 'projects#trending' - end - - # Compatibility with old routing - get 'public' => 'explore/projects#index' - get 'public/projects' => 'explore/projects#index' - - # - # Admin Area - # - namespace :admin do - resources :users, constraints: { id: /[a-zA-Z.\/0-9_\-]+/ } do - resources :keys, only: [:show, :destroy] - resources :identities, except: [:show] - - member do - get :projects - get :keys - get :groups - put :block - put :unblock - put :unlock - put :confirm - post :impersonate - patch :disable_two_factor - delete 'remove/:email_id', action: 'remove_email', as: 'remove_email' - end - end - - resource :impersonation, only: :destroy - - resources :abuse_reports, only: [:index, :destroy] - resources :spam_logs, only: [:index, :destroy] - - resources :applications - - resources :groups, constraints: { id: /[^\/]+/ } do - member do - put :members_update - end - end - - resources :deploy_keys, only: [:index, :new, :create, :destroy] - - resources :hooks, only: [:index, :create, :destroy] do - get :test - end - - resources :broadcast_messages, only: [:index, :edit, :create, :update, :destroy] do - post :preview, on: :collection - end - - resource :logs, only: [:show] - resource :health_check, controller: 'health_check', only: [:show] - resource :background_jobs, controller: 'background_jobs', only: [:show] - resource :system_info, controller: 'system_info', only: [:show] - resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ } - - resources :namespaces, path: '/projects', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: [] do - root to: 'projects#index', as: :projects - - resources(:projects, - path: '/', - constraints: { id: /[a-zA-Z.0-9_\-]+/ }, - only: [:index, :show]) do - root to: 'projects#show' - - member do - put :transfer - post :repository_check - end - - resources :runner_projects, only: [:create, :destroy] - end - end - - resource :appearances, only: [:show, :create, :update], path: 'appearance' do - member do - get :preview - delete :logo - delete :header_logos - end - end - - resource :application_settings, only: [:show, :update] do - resources :services, only: [:index, :edit, :update] - put :reset_runners_token - put :reset_health_check_token - put :clear_repository_check_states - end - - resources :labels - - resources :runners, only: [:index, :show, :update, :destroy] do - member do - get :resume - get :pause - end - end - - resources :builds, only: :index do - collection do - post :cancel_all - end - end - - root to: 'dashboard#index' - end - - # - # Profile Area - # - resource :profile, only: [:show, :update] do - member do - get :audit_log - get :applications, to: 'oauth/applications#index' - - put :reset_private_token - put :update_username - end - - scope module: :profiles do - resource :account, only: [:show] do - member do - delete :unlink - end - end - resource :notifications, only: [:show, :update] - resource :password, only: [:new, :create, :edit, :update] do - member do - put :reset - end - end - resource :preferences, only: [:show, :update] - resources :keys, only: [:index, :show, :new, :create, :destroy] - resources :emails, only: [:index, :create, :destroy] - resource :avatar, only: [:destroy] - - resources :personal_access_tokens, only: [:index, :create] do - member do - put :revoke - end - end - - resource :two_factor_auth, only: [:show, :create, :destroy] do - member do - post :create_u2f - post :codes - patch :skip - end - end - end - end - - scope(path: 'u/:username', - as: :user, - constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ }, - controller: :users) do - get :calendar - get :calendar_activities - get :groups - get :projects - get :contributed, as: :contributed_projects - get :snippets - get '/', action: :show - end - - # - # Dashboard Area - # - resource :dashboard, controller: 'dashboard', only: [] do - get :issues - get :merge_requests - get :activity - - scope module: :dashboard do - resources :milestones, only: [:index, :show] - resources :labels, only: [:index] - - resources :groups, only: [:index] - resources :snippets, only: [:index] - - resources :todos, only: [:index, :destroy] do - collection do - delete :destroy_all - end - end - - resources :projects, only: [:index] do - collection do - get :starred - end - end - end - - root to: "dashboard/projects#index" - end - - # - # Groups Area - # - resources :groups, constraints: { id: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ } do - member do - get :issues - get :merge_requests - get :projects - get :activity - end - - scope module: :groups do - resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do - post :resend_invite, on: :member - delete :leave, on: :collection - end - - resource :avatar, only: [:destroy] - resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] - end - end - - resources :projects, constraints: { id: /[^\/]+/ }, only: [:index, :new, :create] - - devise_for :users, controllers: { omniauth_callbacks: :omniauth_callbacks, - registrations: :registrations, - passwords: :passwords, - sessions: :sessions, - confirmations: :confirmations } - - devise_scope :user do - get '/users/auth/:provider/omniauth_error' => 'omniauth_callbacks#omniauth_error', as: :omniauth_error - get '/users/almost_there' => 'confirmations#almost_there' - end - - root to: "root#index" - - # - # Project Area - # - resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: [] do - resources(:projects, constraints: { id: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ }, except: - [:new, :create, :index], path: "/") do - member do - put :transfer - delete :remove_fork - post :archive - post :unarchive - post :housekeeping - post :toggle_star - post :markdown_preview - post :export - post :remove_export - post :generate_new_export - get :download_export - get :autocomplete_sources - get :activity - get :refs - end - - scope module: :projects do - # Git HTTP clients ('git clone' etc.) - scope constraints: { id: /.+\.git/, format: nil } do - get '/info/refs', to: 'git_http#info_refs' - post '/git-upload-pack', to: 'git_http#git_upload_pack' - post '/git-receive-pack', to: 'git_http#git_receive_pack' - end - - # Allow /info/refs, /info/refs?service=git-upload-pack, and - # /info/refs?service=git-receive-pack, but nothing else. - # - git_http_handshake = lambda do |request| - request.query_string.blank? || - request.query_string.match(/\Aservice=git-(upload|receive)-pack\z/) - end - - ref_redirect = redirect do |params, request| - path = "#{params[:namespace_id]}/#{params[:project_id]}.git/info/refs" - path << "?#{request.query_string}" unless request.query_string.blank? - path - end - - get '/info/refs', constraints: git_http_handshake, to: ref_redirect - - # Blob routes: - get '/new/*id', to: 'blob#new', constraints: { id: /.+/ }, as: 'new_blob' - post '/create/*id', to: 'blob#create', constraints: { id: /.+/ }, as: 'create_blob' - get '/edit/*id', to: 'blob#edit', constraints: { id: /.+/ }, as: 'edit_blob' - put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob' - post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob' - - scope do - get( - '/blob/*id/diff', - to: 'blob#diff', - constraints: { id: /.+/, format: false }, - as: :blob_diff - ) - get( - '/blob/*id', - to: 'blob#show', - constraints: { id: /.+/, format: false }, - as: :blob - ) - delete( - '/blob/*id', - to: 'blob#destroy', - constraints: { id: /.+/, format: false } - ) - put( - '/blob/*id', - to: 'blob#update', - constraints: { id: /.+/, format: false } - ) - post( - '/blob/*id', - to: 'blob#create', - constraints: { id: /.+/, format: false } - ) - end - - scope do - get( - '/raw/*id', - to: 'raw#show', - constraints: { id: /.+/, format: /(html|js)/ }, - as: :raw - ) - end - - scope do - get( - '/tree/*id', - to: 'tree#show', - constraints: { id: /.+/, format: /(html|js)/ }, - as: :tree - ) - end - - scope do - get( - '/find_file/*id', - to: 'find_file#show', - constraints: { id: /.+/, format: /html/ }, - as: :find_file - ) - end - - scope do - get( - '/files/*id', - to: 'find_file#list', - constraints: { id: /(?:[^.]|\.(?!json$))+/, format: /json/ }, - as: :files - ) - end - - scope do - post( - '/create_dir/*id', - to: 'tree#create_dir', - constraints: { id: /.+/ }, - as: 'create_dir' - ) - end - - scope do - get( - '/blame/*id', - to: 'blame#show', - constraints: { id: /.+/, format: /(html|js)/ }, - as: :blame - ) - end - - scope do - get( - '/commits/*id', - to: 'commits#show', - constraints: { id: /(?:[^.]|\.(?!atom$))+/, format: /atom/ }, - as: :commits - ) - end - - resource :avatar, only: [:show, :destroy] - resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do - member do - get :branches - get :builds - post :cancel_builds - post :retry_builds - post :revert - post :cherry_pick - get :diff_for_path - end - end - - resources :compare, only: [:index, :create] do - collection do - get :diff_for_path - end - end - - get '/compare/:from...:to', to: 'compare#show', as: 'compare', constraints: { from: /.+/, to: /.+/ } - - # Don't use format parameter as file extension (old 3.0.x behavior) - # See http://guides.rubyonrails.org/routing.html#route-globbing-and-wildcard-segments - scope format: false do - resources :network, only: [:show], constraints: { id: Gitlab::Regex.git_reference_regex } - - resources :graphs, only: [:show], constraints: { id: Gitlab::Regex.git_reference_regex } do - member do - get :commits - get :ci - get :languages - end - end - end - - resources :snippets, constraints: { id: /\d+/ } do - member do - get 'raw' - end - end - - WIKI_SLUG_ID = { id: /\S+/ } unless defined? WIKI_SLUG_ID - - scope do - # Order matters to give priority to these matches - get '/wikis/git_access', to: 'wikis#git_access' - get '/wikis/pages', to: 'wikis#pages', as: 'wiki_pages' - post '/wikis', to: 'wikis#create' - - get '/wikis/*id/history', to: 'wikis#history', as: 'wiki_history', constraints: WIKI_SLUG_ID - get '/wikis/*id/edit', to: 'wikis#edit', as: 'wiki_edit', constraints: WIKI_SLUG_ID - - get '/wikis/*id', to: 'wikis#show', as: 'wiki', constraints: WIKI_SLUG_ID - delete '/wikis/*id', to: 'wikis#destroy', constraints: WIKI_SLUG_ID - put '/wikis/*id', to: 'wikis#update', constraints: WIKI_SLUG_ID - post '/wikis/*id/markdown_preview', to: 'wikis#markdown_preview', constraints: WIKI_SLUG_ID, as: 'wiki_markdown_preview' - end - - resource :repository, only: [:create] do - member do - get 'archive', constraints: { format: Gitlab::Regex.archive_formats_regex } - end - end - - resources :services, constraints: { id: /[^\/]+/ }, only: [:index, :edit, :update] do - member do - get :test - end - end - - resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do - member do - put :enable - put :disable - end - end - - resources :forks, only: [:index, :new, :create] - resource :import, only: [:new, :create, :show] - - resources :refs, only: [] do - collection do - get 'switch' - end - - member do - # tree viewer logs - get 'logs_tree', constraints: { id: Gitlab::Regex.git_reference_regex } - # Directories with leading dots erroneously get rejected if git - # ref regex used in constraints. Regex verification now done in controller. - get 'logs_tree/*path' => 'refs#logs_tree', as: :logs_file, constraints: { - id: /.*/, - path: /.*/ - } - end - end - - resources :merge_requests, constraints: { id: /\d+/ } do - member do - get :commits - get :diffs - get :builds - get :merge_check - post :merge - post :cancel_merge_when_build_succeeds - get :ci_status - post :toggle_subscription - post :toggle_award_emoji - post :remove_wip - get :diff_for_path - end - - collection do - get :branch_from - get :branch_to - get :update_branches - get :diff_for_path - end - end - - resources :branches, only: [:index, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } - resources :tags, only: [:index, :show, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } do - resource :release, only: [:edit, :update] - end - - resources :protected_branches, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } - resources :variables, only: [:index, :show, :update, :create, :destroy] - resources :triggers, only: [:index, :create, :destroy] - - resources :pipelines, only: [:index, :new, :create, :show] do - collection do - resource :pipelines_settings, path: 'settings', only: [:show, :update] - end - - member do - post :cancel - post :retry - end - end - - resources :environments - - resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do - collection do - post :cancel_all - end - - member do - get :status - post :cancel - post :retry - post :play - post :erase - get :trace - get :raw - end - - resource :artifacts, only: [] do - get :download - get :browse, path: 'browse(/*path)', format: false - get :file, path: 'file/*path', format: false - post :keep - end - end - - resources :hooks, only: [:index, :create, :destroy], constraints: { id: /\d+/ } do - member do - get :test - end - end - - resources :container_registry, only: [:index, :destroy], constraints: { id: Gitlab::Regex.container_registry_reference_regex } - - resources :milestones, constraints: { id: /\d+/ } do - member do - put :sort_issues - put :sort_merge_requests - end - end - - resources :labels, except: [:show], constraints: { id: /\d+/ } do - collection do - post :generate - post :set_priorities - end - - member do - post :toggle_subscription - delete :remove_priority - end - end - - resources :issues, constraints: { id: /\d+/ } do - member do - post :toggle_subscription - post :toggle_award_emoji - get :referenced_merge_requests - get :related_branches - get :can_create_branch - end - collection do - post :bulk_update - end - end - - resources :project_members, except: [:show, :new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do - collection do - delete :leave - - # Used for import team - # from another project - get :import - post :apply_import - end - - member do - post :resend_invite - end - end - - resources :group_links, only: [:index, :create, :destroy], constraints: { id: /\d+/ } - - resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do - member do - post :toggle_award_emoji - delete :delete_attachment - end - end - - resources :todos, only: [:create] - - resources :uploads, only: [:create] do - collection do - get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ } - end - end - - resources :runners, only: [:index, :edit, :update, :destroy, :show] do - member do - get :resume - get :pause - end - - collection do - post :toggle_shared_runners - end - end - - resources :runner_projects, only: [:create, :destroy] - resources :badges, only: [:index] do - collection do - scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do - get :build, constraints: { format: /svg/ } - end - end - end - end - end - end + draw :import + draw :uploads + draw :explore + draw :admin + draw :profile + draw :dashboard + draw :group + draw :user + draw :project # Get all keys of user get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: /.*/ } - get ':id' => 'namespaces#show', constraints: { id: /(?:[^.]|\.(?!atom$))+/, format: /atom/ } + root to: "root#index" end diff --git a/config/routes/admin.rb b/config/routes/admin.rb new file mode 100644 index 00000000000..5ae985da561 --- /dev/null +++ b/config/routes/admin.rb @@ -0,0 +1,102 @@ +namespace :admin do + resources :users, constraints: { id: /[a-zA-Z.\/0-9_\-]+/ } do + resources :keys, only: [:show, :destroy] + resources :identities, except: [:show] + + member do + get :projects + get :keys + get :groups + put :block + put :unblock + put :unlock + put :confirm + post :impersonate + patch :disable_two_factor + delete 'remove/:email_id', action: 'remove_email', as: 'remove_email' + end + end + + resource :impersonation, only: :destroy + + resources :abuse_reports, only: [:index, :destroy] + resources :spam_logs, only: [:index, :destroy] do + member do + post :mark_as_ham + end + end + + resources :applications + + resources :groups, constraints: { id: /[^\/]+/ } do + member do + put :members_update + end + end + + resources :deploy_keys, only: [:index, :new, :create, :destroy] + + resources :hooks, only: [:index, :create, :destroy] do + get :test + end + + resources :broadcast_messages, only: [:index, :edit, :create, :update, :destroy] do + post :preview, on: :collection + end + + resource :logs, only: [:show] + resource :health_check, controller: 'health_check', only: [:show] + resource :background_jobs, controller: 'background_jobs', only: [:show] + resource :system_info, controller: 'system_info', only: [:show] + resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ } + + resources :namespaces, path: '/projects', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: [] do + root to: 'projects#index', as: :projects + + resources(:projects, + path: '/', + constraints: { id: /[a-zA-Z.0-9_\-]+/ }, + only: [:index, :show]) do + root to: 'projects#show' + + member do + put :transfer + post :repository_check + end + + resources :runner_projects, only: [:create, :destroy] + end + end + + resource :appearances, only: [:show, :create, :update], path: 'appearance' do + member do + get :preview + delete :logo + delete :header_logos + end + end + + resource :application_settings, only: [:show, :update] do + resources :services, only: [:index, :edit, :update] + put :reset_runners_token + put :reset_health_check_token + put :clear_repository_check_states + end + + resources :labels + + resources :runners, only: [:index, :show, :update, :destroy] do + member do + get :resume + get :pause + end + end + + resources :builds, only: :index do + collection do + post :cancel_all + end + end + + root to: 'dashboard#index' +end diff --git a/config/routes/api.rb b/config/routes/api.rb new file mode 100644 index 00000000000..69c8efc151c --- /dev/null +++ b/config/routes/api.rb @@ -0,0 +1,2 @@ +API::API.logger Rails.logger +mount API::API => '/api' diff --git a/config/routes/ci.rb b/config/routes/ci.rb new file mode 100644 index 00000000000..47a049d5b20 --- /dev/null +++ b/config/routes/ci.rb @@ -0,0 +1,15 @@ +namespace :ci do + # CI API + Ci::API::API.logger Rails.logger + mount Ci::API::API => '/api' + + resource :lint, only: [:show, :create] + + resources :projects, only: [:index, :show] do + member do + get :status, to: 'projects#badge' + end + end + + root to: 'projects#index' +end diff --git a/config/routes/dashboard.rb b/config/routes/dashboard.rb new file mode 100644 index 00000000000..fb20c63bc63 --- /dev/null +++ b/config/routes/dashboard.rb @@ -0,0 +1,27 @@ +resource :dashboard, controller: 'dashboard', only: [] do + get :issues + get :merge_requests + get :activity + + scope module: :dashboard do + resources :milestones, only: [:index, :show] + resources :labels, only: [:index] + + resources :groups, only: [:index] + resources :snippets, only: [:index] + + resources :todos, only: [:index, :destroy] do + collection do + delete :destroy_all + end + end + + resources :projects, only: [:index] do + collection do + get :starred + end + end + end + + root to: "dashboard/projects#index" +end diff --git a/config/routes/development.rb b/config/routes/development.rb new file mode 100644 index 00000000000..9b2b47c6a21 --- /dev/null +++ b/config/routes/development.rb @@ -0,0 +1,13 @@ +if Rails.env.development? + # Make the built-in Rails routes available in development, otherwise they'd + # get swallowed by the `namespace/project` route matcher below. + # + # See https://git.io/va79N + get '/rails/mailers' => 'rails/mailers#index' + get '/rails/mailers/:path' => 'rails/mailers#preview' + get '/rails/info/properties' => 'rails/info#properties' + get '/rails/info/routes' => 'rails/info#routes' + get '/rails/info' => 'rails/info#index' + + mount LetterOpenerWeb::Engine, at: '/rails/letter_opener' +end diff --git a/config/routes/explore.rb b/config/routes/explore.rb new file mode 100644 index 00000000000..42ec5e8abec --- /dev/null +++ b/config/routes/explore.rb @@ -0,0 +1,16 @@ +namespace :explore do + resources :projects, only: [:index] do + collection do + get :trending + get :starred + end + end + + resources :groups, only: [:index] + resources :snippets, only: [:index] + root to: 'projects#trending' +end + +# Compatibility with old routing +get 'public' => 'explore/projects#index' +get 'public/projects' => 'explore/projects#index' diff --git a/config/routes/group.rb b/config/routes/group.rb new file mode 100644 index 00000000000..47a8a0a53d4 --- /dev/null +++ b/config/routes/group.rb @@ -0,0 +1,26 @@ +require 'constraints/group_url_constrainer' + +constraints(GroupUrlConstrainer.new) do + scope(path: ':id', as: :group, controller: :groups) do + get '/', action: :show + end +end + +resources :groups, constraints: { id: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ } do + member do + get :issues + get :merge_requests + get :projects + get :activity + end + + scope module: :groups do + resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do + post :resend_invite, on: :member + delete :leave, on: :collection + end + + resource :avatar, only: [:destroy] + resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] + end +end diff --git a/config/routes/help.rb b/config/routes/help.rb new file mode 100644 index 00000000000..d53822da9ec --- /dev/null +++ b/config/routes/help.rb @@ -0,0 +1,4 @@ +get 'help' => 'help#index' +get 'help/shortcuts' => 'help#shortcuts' +get 'help/ui' => 'help#ui' +get 'help/*path' => 'help#show', as: :help_page diff --git a/config/routes/import.rb b/config/routes/import.rb new file mode 100644 index 00000000000..89f3b3f6378 --- /dev/null +++ b/config/routes/import.rb @@ -0,0 +1,42 @@ +namespace :import do + resource :github, only: [:create, :new], controller: :github do + post :personal_access_token + get :status + get :callback + get :jobs + end + + resource :gitlab, only: [:create], controller: :gitlab do + get :status + get :callback + get :jobs + end + + resource :bitbucket, only: [:create], controller: :bitbucket do + get :status + get :callback + get :jobs + end + + resource :google_code, only: [:create, :new], controller: :google_code do + get :status + post :callback + get :jobs + + get :new_user_map, path: :user_map + post :create_user_map, path: :user_map + end + + resource :fogbugz, only: [:create, :new], controller: :fogbugz do + get :status + post :callback + get :jobs + + get :new_user_map, path: :user_map + post :create_user_map, path: :user_map + end + + resource :gitlab_project, only: [:create, :new] do + post :create + end +end diff --git a/config/routes/profile.rb b/config/routes/profile.rb new file mode 100644 index 00000000000..4cb68c9b34a --- /dev/null +++ b/config/routes/profile.rb @@ -0,0 +1,43 @@ +resource :profile, only: [:show, :update] do + member do + get :audit_log + get :applications, to: 'oauth/applications#index' + + put :reset_private_token + put :update_username + end + + scope module: :profiles do + resource :account, only: [:show] do + member do + delete :unlink + end + end + resource :notifications, only: [:show, :update] + resource :password, only: [:new, :create, :edit, :update] do + member do + put :reset + end + end + resource :preferences, only: [:show, :update] + resources :keys, only: [:index, :show, :new, :create, :destroy] + resources :emails, only: [:index, :create, :destroy] + resource :avatar, only: [:destroy] + + resources :personal_access_tokens, only: [:index, :create] do + member do + put :revoke + end + end + + resource :two_factor_auth, only: [:show, :create, :destroy] do + member do + post :create_u2f + post :codes + patch :skip + end + end + + resources :u2f_registrations, only: [:destroy] + end +end diff --git a/config/routes/project.rb b/config/routes/project.rb new file mode 100644 index 00000000000..200922b74db --- /dev/null +++ b/config/routes/project.rb @@ -0,0 +1,467 @@ +resources :projects, constraints: { id: /[^\/]+/ }, only: [:index, :new, :create] + +resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: [] do + resources(:projects, constraints: { id: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ }, except: + [:new, :create, :index], path: "/") do + member do + put :transfer + delete :remove_fork + post :archive + post :unarchive + post :housekeeping + post :toggle_star + post :preview_markdown + post :export + post :remove_export + post :generate_new_export + get :download_export + get :autocomplete_sources + get :activity + get :refs + end + + scope module: :projects do + scope constraints: { id: /.+\.git/, format: nil } do + # Git HTTP clients ('git clone' etc.) + get '/info/refs', to: 'git_http#info_refs' + post '/git-upload-pack', to: 'git_http#git_upload_pack' + post '/git-receive-pack', to: 'git_http#git_receive_pack' + + # Git LFS API (metadata) + post '/info/lfs/objects/batch', to: 'lfs_api#batch' + post '/info/lfs/objects', to: 'lfs_api#deprecated' + get '/info/lfs/objects/*oid', to: 'lfs_api#deprecated' + + # GitLab LFS object storage + scope constraints: { oid: /[a-f0-9]{64}/ } do + get '/gitlab-lfs/objects/*oid', to: 'lfs_storage#download' + + scope constraints: { size: /[0-9]+/ } do + put '/gitlab-lfs/objects/*oid/*size/authorize', to: 'lfs_storage#upload_authorize' + put '/gitlab-lfs/objects/*oid/*size', to: 'lfs_storage#upload_finalize' + end + end + end + + # Allow /info/refs, /info/refs?service=git-upload-pack, and + # /info/refs?service=git-receive-pack, but nothing else. + # + git_http_handshake = lambda do |request| + request.query_string.blank? || + request.query_string.match(/\Aservice=git-(upload|receive)-pack\z/) + end + + ref_redirect = redirect do |params, request| + path = "#{params[:namespace_id]}/#{params[:project_id]}.git/info/refs" + path << "?#{request.query_string}" unless request.query_string.blank? + path + end + + get '/info/refs', constraints: git_http_handshake, to: ref_redirect + + # Blob routes: + get '/new/*id', to: 'blob#new', constraints: { id: /.+/ }, as: 'new_blob' + post '/create/*id', to: 'blob#create', constraints: { id: /.+/ }, as: 'create_blob' + get '/edit/*id', to: 'blob#edit', constraints: { id: /.+/ }, as: 'edit_blob' + put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob' + post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob' + + # + # Templates + # + get '/templates/:template_type/:key' => 'templates#show', as: :template + + scope do + get( + '/blob/*id/diff', + to: 'blob#diff', + constraints: { id: /.+/, format: false }, + as: :blob_diff + ) + get( + '/blob/*id', + to: 'blob#show', + constraints: { id: /.+/, format: false }, + as: :blob + ) + delete( + '/blob/*id', + to: 'blob#destroy', + constraints: { id: /.+/, format: false } + ) + put( + '/blob/*id', + to: 'blob#update', + constraints: { id: /.+/, format: false } + ) + post( + '/blob/*id', + to: 'blob#create', + constraints: { id: /.+/, format: false } + ) + end + + scope do + get( + '/raw/*id', + to: 'raw#show', + constraints: { id: /.+/, format: /(html|js)/ }, + as: :raw + ) + end + + scope do + get( + '/tree/*id', + to: 'tree#show', + constraints: { id: /.+/, format: /(html|js)/ }, + as: :tree + ) + end + + scope do + get( + '/find_file/*id', + to: 'find_file#show', + constraints: { id: /.+/, format: /html/ }, + as: :find_file + ) + end + + scope do + get( + '/files/*id', + to: 'find_file#list', + constraints: { id: /(?:[^.]|\.(?!json$))+/, format: /json/ }, + as: :files + ) + end + + scope do + post( + '/create_dir/*id', + to: 'tree#create_dir', + constraints: { id: /.+/ }, + as: 'create_dir' + ) + end + + scope do + get( + '/blame/*id', + to: 'blame#show', + constraints: { id: /.+/, format: /(html|js)/ }, + as: :blame + ) + end + + scope do + get( + '/commits/*id', + to: 'commits#show', + constraints: { id: /.+/, format: false }, + as: :commits + ) + end + + resource :avatar, only: [:show, :destroy] + resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do + member do + get :branches + get :builds + get :pipelines + post :cancel_builds + post :retry_builds + post :revert + post :cherry_pick + get :diff_for_path + end + end + + resources :compare, only: [:index, :create] do + collection do + get :diff_for_path + end + end + + get '/compare/:from...:to', to: 'compare#show', as: 'compare', constraints: { from: /.+/, to: /.+/ } + + # Don't use format parameter as file extension (old 3.0.x behavior) + # See http://guides.rubyonrails.org/routing.html#route-globbing-and-wildcard-segments + scope format: false do + resources :network, only: [:show], constraints: { id: Gitlab::Regex.git_reference_regex } + + resources :graphs, only: [:show], constraints: { id: Gitlab::Regex.git_reference_regex } do + member do + get :commits + get :ci + get :languages + end + end + end + + resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do + member do + get 'raw' + end + end + + WIKI_SLUG_ID = { id: /\S+/ } unless defined? WIKI_SLUG_ID + + scope do + # Order matters to give priority to these matches + get '/wikis/git_access', to: 'wikis#git_access' + get '/wikis/pages', to: 'wikis#pages', as: 'wiki_pages' + post '/wikis', to: 'wikis#create' + + get '/wikis/*id/history', to: 'wikis#history', as: 'wiki_history', constraints: WIKI_SLUG_ID + get '/wikis/*id/edit', to: 'wikis#edit', as: 'wiki_edit', constraints: WIKI_SLUG_ID + + get '/wikis/*id', to: 'wikis#show', as: 'wiki', constraints: WIKI_SLUG_ID + delete '/wikis/*id', to: 'wikis#destroy', constraints: WIKI_SLUG_ID + put '/wikis/*id', to: 'wikis#update', constraints: WIKI_SLUG_ID + post '/wikis/*id/preview_markdown', to: 'wikis#preview_markdown', constraints: WIKI_SLUG_ID, as: 'wiki_preview_markdown' + end + + resource :repository, only: [:create] do + member do + get 'archive', constraints: { format: Gitlab::Regex.archive_formats_regex } + end + end + + resources :services, constraints: { id: /[^\/]+/ }, only: [:index, :edit, :update] do + member do + get :test + end + end + + resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create] do + member do + put :enable + put :disable + end + end + + resources :forks, only: [:index, :new, :create] + resource :import, only: [:new, :create, :show] + + resources :refs, only: [] do + collection do + get 'switch' + end + + member do + # tree viewer logs + get 'logs_tree', constraints: { id: Gitlab::Regex.git_reference_regex } + # Directories with leading dots erroneously get rejected if git + # ref regex used in constraints. Regex verification now done in controller. + get 'logs_tree/*path' => 'refs#logs_tree', as: :logs_file, constraints: { + id: /.*/, + path: /.*/ + } + end + end + + resources :merge_requests, concerns: :awardable, constraints: { id: /\d+/ } do + member do + get :commits + get :diffs + get :conflicts + get :builds + get :pipelines + get :merge_check + post :merge + post :cancel_merge_when_build_succeeds + get :ci_status + get :ci_environments_status + post :toggle_subscription + post :remove_wip + get :diff_for_path + post :resolve_conflicts + post :assign_related_issues + end + + collection do + get :branch_from + get :branch_to + get :update_branches + get :diff_for_path + post :bulk_update + get :new_diffs, path: 'new/diffs' + end + + resources :discussions, only: [], constraints: { id: /\h{40}/ } do + member do + post :resolve + delete :resolve, action: :unresolve + end + end + end + + resources :branches, only: [:index, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } + resources :tags, only: [:index, :show, :new, :create, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } do + resource :release, only: [:edit, :update] + end + + resources :protected_branches, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } + resources :variables, only: [:index, :show, :update, :create, :destroy] + resources :triggers, only: [:index, :create, :destroy] + + resources :pipelines, only: [:index, :new, :create, :show] do + collection do + resource :pipelines_settings, path: 'settings', only: [:show, :update] + end + + member do + post :cancel + post :retry + end + end + + resources :environments + + resource :cycle_analytics, only: [:show] + + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do + collection do + post :cancel_all + + resources :artifacts, only: [] do + collection do + get :latest_succeeded, + path: '*ref_name_and_path', + format: false + end + end + end + + member do + get :status + post :cancel + post :retry + post :play + post :erase + get :trace + get :raw + end + + resource :artifacts, only: [] do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + post :keep + end + end + + resources :hooks, only: [:index, :create, :destroy], constraints: { id: /\d+/ } do + member do + get :test + end + end + + resources :container_registry, only: [:index, :destroy], constraints: { id: Gitlab::Regex.container_registry_reference_regex } + + resources :milestones, constraints: { id: /\d+/ } do + member do + put :sort_issues + put :sort_merge_requests + end + end + + resources :labels, except: [:show], constraints: { id: /\d+/ } do + collection do + post :generate + post :set_priorities + end + + member do + post :toggle_subscription + delete :remove_priority + end + end + + resources :issues, concerns: :awardable, constraints: { id: /\d+/ } do + member do + post :toggle_subscription + post :mark_as_spam + get :referenced_merge_requests + get :related_branches + get :can_create_branch + end + collection do + post :bulk_update + end + end + + resources :project_members, except: [:show, :new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do + collection do + delete :leave + + # Used for import team + # from another project + get :import + post :apply_import + end + + member do + post :resend_invite + end + end + + resources :group_links, only: [:index, :create, :destroy], constraints: { id: /\d+/ } + + resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } do + member do + delete :delete_attachment + post :resolve + delete :resolve, action: :unresolve + end + end + + resources :boards, only: [:index, :show] do + scope module: :boards do + resources :issues, only: [:update] + + resources :lists, only: [:index, :create, :update, :destroy] do + collection do + post :generate + end + + resources :issues, only: [:index, :create] + end + end + end + + resources :todos, only: [:create] + + resources :uploads, only: [:create] do + collection do + get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ } + end + end + + resources :runners, only: [:index, :edit, :update, :destroy, :show] do + member do + get :resume + get :pause + end + + collection do + post :toggle_shared_runners + end + end + + resources :runner_projects, only: [:create, :destroy] + resources :badges, only: [:index] do + collection do + scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do + constraints format: /svg/ do + get :build + get :coverage + end + end + end + end + end + end +end diff --git a/config/routes/sherlock.rb b/config/routes/sherlock.rb new file mode 100644 index 00000000000..c9969f91c36 --- /dev/null +++ b/config/routes/sherlock.rb @@ -0,0 +1,12 @@ +if Gitlab::Sherlock.enabled? + namespace :sherlock do + resources :transactions, only: [:index, :show] do + resources :queries, only: [:show] + resources :file_samples, only: [:show] + + collection do + delete :destroy_all + end + end + end +end diff --git a/config/routes/sidekiq.rb b/config/routes/sidekiq.rb new file mode 100644 index 00000000000..d3e6bc4c292 --- /dev/null +++ b/config/routes/sidekiq.rb @@ -0,0 +1,4 @@ +constraint = lambda { |request| request.env['warden'].authenticate? and request.env['warden'].user.admin? } +constraints constraint do + mount Sidekiq::Web, at: '/admin/sidekiq', as: :sidekiq +end diff --git a/config/routes/snippets.rb b/config/routes/snippets.rb new file mode 100644 index 00000000000..3ca096f31ba --- /dev/null +++ b/config/routes/snippets.rb @@ -0,0 +1,9 @@ +resources :snippets, concerns: :awardable do + member do + get 'raw' + get 'download' + end +end + +get '/s/:username', to: redirect('/u/%{username}/snippets'), + constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ } diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb new file mode 100644 index 00000000000..2b22148a134 --- /dev/null +++ b/config/routes/uploads.rb @@ -0,0 +1,21 @@ +scope path: :uploads do + # Note attachments and User/Group/Project avatars + get ":model/:mounted_as/:id/:filename", + to: "uploads#show", + constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ } + + # Appearance + get ":model/:mounted_as/:id/:filename", + to: "uploads#show", + constraints: { model: /appearance/, mounted_as: /logo|header_logo/, filename: /.+/ } + + # Project markdown uploads + get ":namespace_id/:project_id/:secret/:filename", + to: "projects/uploads#show", + constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } +end + +# Redirect old note attachments path to new uploads path. +get "files/note/:id/:filename", + to: redirect("uploads/note/attachment/%{id}/%{filename}"), + constraints: { filename: /[^\/]+/ } diff --git a/config/routes/user.rb b/config/routes/user.rb new file mode 100644 index 00000000000..54bbcb18f6a --- /dev/null +++ b/config/routes/user.rb @@ -0,0 +1,37 @@ +require 'constraints/user_url_constrainer' + +get '/u/:username', to: redirect('/%{username}'), + constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ } + +devise_for :users, controllers: { omniauth_callbacks: :omniauth_callbacks, + registrations: :registrations, + passwords: :passwords, + sessions: :sessions, + confirmations: :confirmations } + +devise_scope :user do + get '/users/auth/:provider/omniauth_error' => 'omniauth_callbacks#omniauth_error', as: :omniauth_error + get '/users/almost_there' => 'confirmations#almost_there' +end + +constraints(UserUrlConstrainer.new) do + scope(path: ':username', + as: :user, + constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ }, + controller: :users) do + get '/', action: :show + end +end + +scope(path: 'u/:username', + as: :user, + constraints: { username: /[a-zA-Z.0-9_\-]+(?<!\.atom)/ }, + controller: :users) do + get :calendar + get :calendar_activities + get :groups + get :projects + get :contributed, as: :contributed_projects + get :snippets + get '/', to: redirect('/%{username}') +end diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb index e3316ecdb6c..a984eda5ab5 100644 --- a/db/fixtures/development/04_project.rb +++ b/db/fixtures/development/04_project.rb @@ -3,11 +3,11 @@ require 'sidekiq/testing' Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do project_urls = [ - 'https://github.com/documentcloud/underscore.git', + 'https://gitlab.com/gitlab-org/gitlab-test.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', 'https://gitlab.com/gitlab-org/gitlab-ci.git', 'https://gitlab.com/gitlab-org/gitlab-shell.git', - 'https://gitlab.com/gitlab-org/gitlab-test.git', + 'https://github.com/documentcloud/underscore.git', 'https://github.com/twitter/flight.git', 'https://github.com/twitter/typeahead.js.git', 'https://github.com/h5bp/html5-boilerplate.git', @@ -38,12 +38,7 @@ Sidekiq::Testing.inline! do ] # You can specify how many projects you need during seed execution - size = if ENV['SIZE'].present? - ENV['SIZE'].to_i - else - 8 - end - + size = ENV['SIZE'].present? ? ENV['SIZE'].to_i : 8 project_urls.first(size).each_with_index do |url, i| group_path, project_path = url.split('/')[-2..-1] diff --git a/db/fixtures/development/06_teams.rb b/db/fixtures/development/06_teams.rb index 3e8cdcd67b4..9739a5ac8d5 100644 --- a/db/fixtures/development/06_teams.rb +++ b/db/fixtures/development/06_teams.rb @@ -1,7 +1,7 @@ Gitlab::Seeder.quiet do Group.all.each do |group| User.all.sample(4).each do |user| - if group.add_users([user.id], Gitlab::Access.values.sample) + if group.add_user(user, Gitlab::Access.values.sample).persisted? print '.' else print 'F' diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb deleted file mode 100644 index 124704cb451..00000000000 --- a/db/fixtures/development/14_builds.rb +++ /dev/null @@ -1,123 +0,0 @@ -class Gitlab::Seeder::Builds - STAGES = %w[build notify_build test notify_test deploy notify_deploy] - - def initialize(project) - @project = project - end - - def seed! - pipelines.each do |pipeline| - begin - build_create!(pipeline, name: 'build:linux', stage: 'build') - build_create!(pipeline, name: 'build:osx', stage: 'build') - - build_create!(pipeline, name: 'slack post build', stage: 'notify_build') - - build_create!(pipeline, name: 'rspec:linux', stage: 'test') - build_create!(pipeline, name: 'rspec:windows', stage: 'test') - build_create!(pipeline, name: 'rspec:windows', stage: 'test') - build_create!(pipeline, name: 'rspec:osx', stage: 'test') - build_create!(pipeline, name: 'spinach:linux', stage: 'test') - build_create!(pipeline, name: 'spinach:osx', stage: 'test') - build_create!(pipeline, name: 'cucumber:linux', stage: 'test') - build_create!(pipeline, name: 'cucumber:osx', stage: 'test') - - build_create!(pipeline, name: 'slack post test', stage: 'notify_test') - - build_create!(pipeline, name: 'staging', stage: 'deploy', environment: 'staging') - build_create!(pipeline, name: 'production', stage: 'deploy', environment: 'production', when: 'manual') - - commit_status_create!(pipeline, name: 'jenkins') - - print '.' - rescue ActiveRecord::RecordInvalid - print 'F' - end - end - end - - def pipelines - commits = @project.repository.commits('master', limit: 5) - commits_sha = commits.map { |commit| commit.raw.id } - commits_sha.map do |sha| - @project.ensure_pipeline(sha, 'master') - end - rescue - [] - end - - def build_create!(pipeline, opts = {}) - attributes = build_attributes_for(pipeline, opts) - build = Ci::Build.new(attributes) - - if opts[:name].start_with?('build') - artifacts_cache_file(artifacts_archive_path) do |file| - build.artifacts_file = file - end - - artifacts_cache_file(artifacts_metadata_path) do |file| - build.artifacts_metadata = file - end - end - - build.save! - build.update(status: build_status) - - if %w(running success failed).include?(build.status) - # We need to set build trace after saving a build (id required) - build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") - end - end - - def commit_status_create!(pipeline, opts = {}) - attributes = commit_status_attributes_for(pipeline, opts) - GenericCommitStatus.create(attributes) - end - - def commit_status_attributes_for(pipeline, opts) - { name: 'test build', stage: 'test', stage_idx: stage_index(opts[:stage]), - ref: 'master', user: build_user, project: @project, pipeline: pipeline, - created_at: Time.now, updated_at: Time.now - }.merge(opts) - end - - def build_attributes_for(pipeline, opts) - commit_status_attributes_for(pipeline, opts).merge(commands: '$ build command') - end - - def build_user - @project.team.users.sample - end - - def build_status - Ci::Build::AVAILABLE_STATUSES.sample - end - - def stage_index(stage) - STAGES.index(stage) || 0 - end - - def artifacts_archive_path - Rails.root + 'spec/fixtures/ci_build_artifacts.zip' - end - - def artifacts_metadata_path - Rails.root + 'spec/fixtures/ci_build_artifacts_metadata.gz' - end - - def artifacts_cache_file(file_path) - cache_path = file_path.to_s.gsub('ci_', "p#{@project.id}_") - - FileUtils.copy(file_path, cache_path) - File.open(cache_path) do |file| - yield file - end - end -end - -Gitlab::Seeder.quiet do - Project.all.sample(10).each do |project| - project_builds = Gitlab::Seeder::Builds.new(project) - project_builds.seed! - end -end diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb new file mode 100644 index 00000000000..803cbca584d --- /dev/null +++ b/db/fixtures/development/14_pipelines.rb @@ -0,0 +1,157 @@ +class Gitlab::Seeder::Pipelines + STAGES = %w[build test deploy notify] + BUILDS = [ + { name: 'build:linux', stage: 'build', status: :success }, + { name: 'build:osx', stage: 'build', status: :success }, + { name: 'rspec:linux 0 3', stage: 'test', status: :success }, + { name: 'rspec:linux 1 3', stage: 'test', status: :success }, + { name: 'rspec:linux 2 3', stage: 'test', status: :success }, + { name: 'rspec:windows 0 3', stage: 'test', status: :success }, + { name: 'rspec:windows 1 3', stage: 'test', status: :success }, + { name: 'rspec:windows 2 3', stage: 'test', status: :success }, + { name: 'rspec:windows 2 3', stage: 'test', status: :success }, + { name: 'rspec:osx', stage: 'test', status_event: :success }, + { name: 'spinach:linux', stage: 'test', status: :success }, + { name: 'spinach:osx', stage: 'test', status: :failed, allow_failure: true}, + { name: 'env:alpha', stage: 'deploy', environment: 'alpha', status: :pending }, + { name: 'env:beta', stage: 'deploy', environment: 'beta', status: :running }, + { name: 'env:gamma', stage: 'deploy', environment: 'gamma', status: :canceled }, + { name: 'staging', stage: 'deploy', environment: 'staging', status_event: :success }, + { name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :skipped }, + { name: 'slack', stage: 'notify', when: 'manual', status: :created }, + ] + + def initialize(project) + @project = project + end + + def seed! + pipelines.each do |pipeline| + begin + BUILDS.each { |opts| build_create!(pipeline, opts) } + commit_status_create!(pipeline, name: 'jenkins', stage: 'test', status: :success) + print '.' + rescue ActiveRecord::RecordInvalid + print 'F' + ensure + pipeline.update_status + end + end + end + + private + + def pipelines + create_master_pipelines + create_merge_request_pipelines + end + + def create_master_pipelines + @project.repository.commits('master', limit: 4).map do |commit| + create_pipeline!(@project, 'master', commit) + end + rescue + [] + end + + def create_merge_request_pipelines + pipelines = @project.merge_requests.first(3).map do |merge_request| + project = merge_request.source_project + branch = merge_request.source_branch + + merge_request.commits.last(4).map do |commit| + create_pipeline!(project, branch, commit) + end + end + + pipelines.flatten + rescue + [] + end + + + def create_pipeline!(project, ref, commit) + project.pipelines.create(sha: commit.id, ref: ref) + end + + def build_create!(pipeline, opts = {}) + attributes = job_attributes(pipeline, opts) + .merge(commands: '$ build command') + + Ci::Build.create!(attributes).tap do |build| + # We need to set build trace and artifacts after saving a build + # (id required), that is why we need `#tap` method instead of passing + # block directly to `Ci::Build#create!`. + + setup_artifacts(build) + setup_build_log(build) + build.save + end + end + + def setup_artifacts(build) + return unless %w[build test].include?(build.stage) + + artifacts_cache_file(artifacts_archive_path) do |file| + build.artifacts_file = file + end + + artifacts_cache_file(artifacts_metadata_path) do |file| + build.artifacts_metadata = file + end + end + + def setup_build_log(build) + if %w(running success failed).include?(build.status) + build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") + end + end + + def commit_status_create!(pipeline, opts = {}) + attributes = job_attributes(pipeline, opts) + + GenericCommitStatus.create!(attributes) + end + + def job_attributes(pipeline, opts) + { name: 'test build', stage: 'test', stage_idx: stage_index(opts[:stage]), + ref: 'master', tag: false, user: build_user, project: @project, pipeline: pipeline, + created_at: Time.now, updated_at: Time.now + }.merge(opts) + end + + def build_user + @project.team.users.sample + end + + def build_status + Ci::Build::AVAILABLE_STATUSES.sample + end + + def stage_index(stage) + STAGES.index(stage) || 0 + end + + def artifacts_archive_path + Rails.root + 'spec/fixtures/ci_build_artifacts.zip' + end + + def artifacts_metadata_path + Rails.root + 'spec/fixtures/ci_build_artifacts_metadata.gz' + end + + def artifacts_cache_file(file_path) + cache_path = file_path.to_s.gsub('ci_', "p#{@project.id}_") + + FileUtils.copy(file_path, cache_path) + File.open(cache_path) do |file| + yield file + end + end +end + +Gitlab::Seeder.quiet do + Project.all.sample(5).each do |project| + project_builds = Gitlab::Seeder::Pipelines.new(project) + project_builds.seed! + end +end diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb new file mode 100644 index 00000000000..e882a492757 --- /dev/null +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -0,0 +1,246 @@ +require 'sidekiq/testing' +require './spec/support/test_env' + +class Gitlab::Seeder::CycleAnalytics + def initialize(project, perf: false) + @project = project + @user = User.order(:id).last + @issue_count = perf ? 1000 : 5 + stub_git_pre_receive! + end + + # The GitLab API needn't be running for the fixtures to be + # created. Since we're performing a number of git actions + # here (like creating a branch or committing a file), we need + # to disable the `pre_receive` hook in order to remove this + # dependency on the GitLab API. + def stub_git_pre_receive! + GitHooksService.class_eval do + def run_hook(name) + [true, ''] + end + end + end + + def seed_metrics! + @issue_count.times do |index| + # Issue + Timecop.travel 5.days.from_now + title = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}" + issue = Issue.create(project: @project, title: title, author: @user) + issue_metrics = issue.metrics + + # Milestones / Labels + Timecop.travel 5.days.from_now + if index.even? + issue_metrics.first_associated_with_milestone_at = rand(6..12).hours.from_now + else + issue_metrics.first_added_to_board_at = rand(6..12).hours.from_now + end + + # Commit + Timecop.travel 5.days.from_now + issue_metrics.first_mentioned_in_commit_at = rand(6..12).hours.from_now + + # MR + Timecop.travel 5.days.from_now + branch_name = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}" + @project.repository.add_branch(@user, branch_name, 'master') + merge_request = MergeRequest.create(target_project: @project, source_project: @project, source_branch: branch_name, target_branch: 'master', title: branch_name, author: @user) + merge_request_metrics = merge_request.metrics + + # MR closing issues + Timecop.travel 5.days.from_now + MergeRequestsClosingIssues.create!(issue: issue, merge_request: merge_request) + + # Merge + Timecop.travel 5.days.from_now + merge_request_metrics.merged_at = rand(6..12).hours.from_now + + # Start build + Timecop.travel 5.days.from_now + merge_request_metrics.latest_build_started_at = rand(6..12).hours.from_now + + # Finish build + Timecop.travel 5.days.from_now + merge_request_metrics.latest_build_finished_at = rand(6..12).hours.from_now + + # Deploy to production + Timecop.travel 5.days.from_now + merge_request_metrics.first_deployed_to_production_at = rand(6..12).hours.from_now + + issue_metrics.save! + merge_request_metrics.save! + + print '.' + end + end + + def seed! + Sidekiq::Testing.inline! do + issues = create_issues + puts '.' + + # Stage 1 + Timecop.travel 5.days.from_now + add_milestones_and_list_labels(issues) + print '.' + + # Stage 2 + Timecop.travel 5.days.from_now + branches = mention_in_commits(issues) + print '.' + + # Stage 3 + Timecop.travel 5.days.from_now + merge_requests = create_merge_requests_closing_issues(issues, branches) + print '.' + + # Stage 4 + Timecop.travel 5.days.from_now + run_builds(merge_requests) + print '.' + + # Stage 5 + Timecop.travel 5.days.from_now + merge_merge_requests(merge_requests) + print '.' + + # Stage 6 / 7 + Timecop.travel 5.days.from_now + deploy_to_production(merge_requests) + print '.' + end + + print '.' + end + + private + + def create_issues + Array.new(@issue_count) do + issue_params = { + title: "Cycle Analytics: #{FFaker::Lorem.sentence(6)}", + description: FFaker::Lorem.sentence, + state: 'opened', + assignee: @project.team.users.sample + } + + Issues::CreateService.new(@project, @project.team.users.sample, issue_params).execute + end + end + + def add_milestones_and_list_labels(issues) + issues.shuffle.map.with_index do |issue, index| + Timecop.travel 12.hours.from_now + + if index.even? + issue.update(milestone: @project.milestones.sample) + else + label_name = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}" + list_label = FactoryGirl.create(:label, title: label_name, project: issue.project) + FactoryGirl.create(:list, board: FactoryGirl.create(:board, project: issue.project), label: list_label) + issue.update(labels: [list_label]) + end + + issue + end + end + + def mention_in_commits(issues) + issues.map do |issue| + Timecop.travel 12.hours.from_now + + branch_name = filename = "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}" + + issue.project.repository.add_branch(@user, branch_name, 'master') + + options = { + committer: issue.project.repository.user_to_committer(@user), + author: issue.project.repository.user_to_committer(@user), + commit: { message: "Commit for ##{issue.iid}", branch: branch_name, update_ref: true }, + file: { content: "content", path: filename, update: false } + } + + commit_sha = Gitlab::Git::Blob.commit(issue.project.repository, options) + issue.project.repository.commit(commit_sha) + + + GitPushService.new(issue.project, + @user, + oldrev: issue.project.repository.commit("master").sha, + newrev: commit_sha, + ref: 'refs/heads/master').execute + + branch_name + end + end + + def create_merge_requests_closing_issues(issues, branches) + issues.zip(branches).map do |issue, branch| + Timecop.travel 12.hours.from_now + + opts = { + title: 'Cycle Analytics merge_request', + description: "Fixes #{issue.to_reference}", + source_branch: branch, + target_branch: 'master' + } + + MergeRequests::CreateService.new(issue.project, @user, opts).execute + end + end + + def run_builds(merge_requests) + merge_requests.each do |merge_request| + Timecop.travel 12.hours.from_now + + service = Ci::CreatePipelineService.new(merge_request.project, + @user, + ref: "refs/heads/#{merge_request.source_branch}") + pipeline = service.execute(ignore_skip_ci: true, save_on_errors: false) + + pipeline.run! + Timecop.travel rand(1..6).hours.from_now + pipeline.succeed! + end + end + + def merge_merge_requests(merge_requests) + merge_requests.each do |merge_request| + Timecop.travel 12.hours.from_now + + MergeRequests::MergeService.new(merge_request.project, @user).execute(merge_request) + end + end + + def deploy_to_production(merge_requests) + merge_requests.each do |merge_request| + Timecop.travel 12.hours.from_now + + CreateDeploymentService.new(merge_request.project, @user, { + environment: 'production', + ref: 'master', + tag: false, + sha: @project.repository.commit('master').sha + }).execute + end + end +end + +Gitlab::Seeder.quiet do + if ENV['SEED_CYCLE_ANALYTICS'] + Project.all.each do |project| + seeder = Gitlab::Seeder::CycleAnalytics.new(project) + seeder.seed! + end + elsif ENV['CYCLE_ANALYTICS_PERF_TEST'] + seeder = Gitlab::Seeder::CycleAnalytics.new(Project.order(:id).first, perf: true) + seeder.seed! + elsif ENV['CYCLE_ANALYTICS_POPULATE_METRICS_DIRECTLY'] + seeder = Gitlab::Seeder::CycleAnalytics.new(Project.order(:id).first, perf: true) + seeder.seed_metrics! + else + puts "Not running the cycle analytics seed file. Use the `SEED_CYCLE_ANALYTICS` environment variable to enable it." + end +end diff --git a/db/migrate/20140407135544_fix_namespaces.rb b/db/migrate/20140407135544_fix_namespaces.rb index 91374966698..0026ce645a6 100644 --- a/db/migrate/20140407135544_fix_namespaces.rb +++ b/db/migrate/20140407135544_fix_namespaces.rb @@ -1,8 +1,14 @@ # rubocop:disable all class FixNamespaces < ActiveRecord::Migration + DOWNTIME = false + def up - Namespace.where('name <> path and type is null').each do |namespace| - namespace.update_attribute(:name, namespace.path) + namespaces = exec_query('SELECT id, path FROM namespaces WHERE name <> path and type is null') + + namespaces.each do |row| + id = row['id'] + path = row['path'] + exec_query("UPDATE namespaces SET name = '#{path}' WHERE id = #{id}") end end diff --git a/db/migrate/20140502125220_migrate_repo_size.rb b/db/migrate/20140502125220_migrate_repo_size.rb index 84463727b3b..e8de7ccf3db 100644 --- a/db/migrate/20140502125220_migrate_repo_size.rb +++ b/db/migrate/20140502125220_migrate_repo_size.rb @@ -1,12 +1,15 @@ # rubocop:disable all class MigrateRepoSize < ActiveRecord::Migration + DOWNTIME = false + def up project_data = execute('SELECT projects.id, namespaces.path AS namespace_path, projects.path AS project_path FROM projects LEFT JOIN namespaces ON projects.namespace_id = namespaces.id') project_data.each do |project| id = project['id'] namespace_path = project['namespace_path'] || '' - path = File.join(Gitlab.config.gitlab_shell.repos_path, namespace_path, project['project_path'] + '.git') + repos_path = Gitlab.config.gitlab_shell['repos_path'] || Gitlab.config.repositories.storages.default + path = File.join(repos_path, namespace_path, project['project_path'] + '.git') begin repo = Gitlab::Git::Repository.new(path) diff --git a/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.rb b/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.rb index fa93936ced7..1db0df92bec 100644 --- a/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.rb +++ b/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.rb @@ -14,7 +14,7 @@ class MoveFromDevelopersCanMergeToProtectedBranchesMergeAccess < ActiveRecord::M def up execute <<-HEREDOC INSERT into protected_branch_merge_access_levels (protected_branch_id, access_level, created_at, updated_at) - SELECT id, (CASE WHEN developers_can_merge THEN 1 ELSE 0 END), now(), now() + SELECT id, (CASE WHEN developers_can_merge THEN 30 ELSE 40 END), now(), now() FROM protected_branches HEREDOC end @@ -23,7 +23,7 @@ class MoveFromDevelopersCanMergeToProtectedBranchesMergeAccess < ActiveRecord::M execute <<-HEREDOC UPDATE protected_branches SET developers_can_merge = TRUE WHERE id IN (SELECT protected_branch_id FROM protected_branch_merge_access_levels - WHERE access_level = 1); + WHERE access_level = 30); HEREDOC end end diff --git a/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.rb b/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.rb index 56f6159d1d8..5c3e189bb5b 100644 --- a/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.rb +++ b/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.rb @@ -14,7 +14,7 @@ class MoveFromDevelopersCanPushToProtectedBranchesPushAccess < ActiveRecord::Mig def up execute <<-HEREDOC INSERT into protected_branch_push_access_levels (protected_branch_id, access_level, created_at, updated_at) - SELECT id, (CASE WHEN developers_can_push THEN 1 ELSE 0 END), now(), now() + SELECT id, (CASE WHEN developers_can_push THEN 30 ELSE 40 END), now(), now() FROM protected_branches HEREDOC end @@ -23,7 +23,7 @@ class MoveFromDevelopersCanPushToProtectedBranchesPushAccess < ActiveRecord::Mig execute <<-HEREDOC UPDATE protected_branches SET developers_can_push = TRUE WHERE id IN (SELECT protected_branch_id FROM protected_branch_push_access_levels - WHERE access_level = 1); + WHERE access_level = 30); HEREDOC end end diff --git a/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb b/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb index f563f660ddf..52a9819c628 100644 --- a/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb +++ b/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb @@ -14,6 +14,6 @@ class RemoveDevelopersCanPushFromProtectedBranches < ActiveRecord::Migration end def down - add_column_with_default(:protected_branches, :developers_can_push, :boolean, default: false, null: false) + add_column_with_default(:protected_branches, :developers_can_push, :boolean, default: false, allow_null: false) end end diff --git a/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb b/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb index aa71e06d36e..4a7bde7f9f3 100644 --- a/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb +++ b/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb @@ -14,6 +14,6 @@ class RemoveDevelopersCanMergeFromProtectedBranches < ActiveRecord::Migration end def down - add_column_with_default(:protected_branches, :developers_can_merge, :boolean, default: false, null: false) + add_column_with_default(:protected_branches, :developers_can_merge, :boolean, default: false, allow_null: false) end end diff --git a/db/migrate/20160707104333_add_lock_to_issuables.rb b/db/migrate/20160707104333_add_lock_to_issuables.rb new file mode 100644 index 00000000000..54866d02cbc --- /dev/null +++ b/db/migrate/20160707104333_add_lock_to_issuables.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLockToIssuables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + add_column :issues, :lock_version, :integer + add_column :merge_requests, :lock_version, :integer + end + + def down + remove_column :issues, :lock_version + remove_column :merge_requests, :lock_version + end +end diff --git a/db/migrate/20160716115711_add_queued_at_to_ci_builds.rb b/db/migrate/20160716115711_add_queued_at_to_ci_builds.rb new file mode 100644 index 00000000000..756910a1fa0 --- /dev/null +++ b/db/migrate/20160716115711_add_queued_at_to_ci_builds.rb @@ -0,0 +1,9 @@ +class AddQueuedAtToCiBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_builds, :queued_at, :timestamp + end +end diff --git a/db/migrate/20160724205507_add_resolved_to_notes.rb b/db/migrate/20160724205507_add_resolved_to_notes.rb new file mode 100644 index 00000000000..b8ebcdbd156 --- /dev/null +++ b/db/migrate/20160724205507_add_resolved_to_notes.rb @@ -0,0 +1,10 @@ +class AddResolvedToNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :notes, :resolved_at, :datetime + add_column :notes, :resolved_by_id, :integer + end +end diff --git a/db/migrate/20160725104020_merge_request_diff_remove_uniq.rb b/db/migrate/20160725104020_merge_request_diff_remove_uniq.rb new file mode 100644 index 00000000000..75a3eb15124 --- /dev/null +++ b/db/migrate/20160725104020_merge_request_diff_remove_uniq.rb @@ -0,0 +1,35 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MergeRequestDiffRemoveUniq < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + DOWNTIME = false + + def up + constraint_name = 'merge_request_diffs_merge_request_id_key' + + transaction do + if index_exists?(:merge_request_diffs, :merge_request_id) + remove_index(:merge_request_diffs, :merge_request_id) + end + + # In some bizarre cases PostgreSQL might have a separate unique constraint + # that we'll need to drop. + if constraint_exists?(constraint_name) && Gitlab::Database.postgresql? + execute("ALTER TABLE merge_request_diffs DROP CONSTRAINT IF EXISTS #{constraint_name};") + end + end + end + + def down + unless index_exists?(:merge_request_diffs, :merge_request_id) + add_concurrent_index(:merge_request_diffs, :merge_request_id, unique: true) + end + end + + def constraint_exists?(name) + indexes(:merge_request_diffs).map(&:name).include?(name) + end +end diff --git a/db/migrate/20160725104452_merge_request_diff_add_index.rb b/db/migrate/20160725104452_merge_request_diff_add_index.rb new file mode 100644 index 00000000000..6d04242dd25 --- /dev/null +++ b/db/migrate/20160725104452_merge_request_diff_add_index.rb @@ -0,0 +1,17 @@ +class MergeRequestDiffAddIndex < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + add_concurrent_index :merge_request_diffs, :merge_request_id + end + + def down + if index_exists?(:merge_request_diffs, :merge_request_id) + remove_index :merge_request_diffs, :merge_request_id + end + end +end diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb new file mode 100644 index 00000000000..ed4ccfedc0a --- /dev/null +++ b/db/migrate/20160727163552_create_user_agent_details.rb @@ -0,0 +1,18 @@ +class CreateUserAgentDetails < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table :user_agent_details do |t| + t.string :user_agent, null: false + t.string :ip_address, null: false + t.integer :subject_id, null: false + t.string :subject_type, null: false + t.boolean :submitted, default: false, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160727191041_create_boards.rb b/db/migrate/20160727191041_create_boards.rb new file mode 100644 index 00000000000..56afbd4e030 --- /dev/null +++ b/db/migrate/20160727191041_create_boards.rb @@ -0,0 +1,13 @@ +class CreateBoards < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :boards do |t| + t.references :project, index: true, foreign_key: true, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160727193336_create_lists.rb b/db/migrate/20160727193336_create_lists.rb new file mode 100644 index 00000000000..61d501215f2 --- /dev/null +++ b/db/migrate/20160727193336_create_lists.rb @@ -0,0 +1,16 @@ +class CreateLists < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :lists do |t| + t.references :board, index: true, foreign_key: true, null: false + t.references :label, index: true, foreign_key: true + t.integer :list_type, null: false, default: 1 + t.integer :position + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb new file mode 100644 index 00000000000..b800e6d7283 --- /dev/null +++ b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb @@ -0,0 +1,16 @@ +class AddPipelineEventsToWebHooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:web_hooks, :pipeline_events, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:web_hooks, :pipeline_events) + end +end diff --git a/db/migrate/20160728103734_add_pipeline_events_to_services.rb b/db/migrate/20160728103734_add_pipeline_events_to_services.rb new file mode 100644 index 00000000000..bcd24fe1566 --- /dev/null +++ b/db/migrate/20160728103734_add_pipeline_events_to_services.rb @@ -0,0 +1,16 @@ +class AddPipelineEventsToServices < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:services, :pipeline_events, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:services, :pipeline_events) + end +end diff --git a/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb new file mode 100644 index 00000000000..e28ab31d629 --- /dev/null +++ b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveProjectIdFromSpamLogs < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Removing a column that contains data that is not used anywhere.' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + remove_column :spam_logs, :project_id, :integer + end +end diff --git a/db/migrate/20160801163421_add_expires_at_to_member.rb b/db/migrate/20160801163421_add_expires_at_to_member.rb new file mode 100644 index 00000000000..8db0fc60c4b --- /dev/null +++ b/db/migrate/20160801163421_add_expires_at_to_member.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddExpiresAtToMember < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :members, :expires_at, :date + end +end diff --git a/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb new file mode 100644 index 00000000000..296f1dfac7b --- /dev/null +++ b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb @@ -0,0 +1,20 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddSubmittedAsHamToSpamLogs < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + disable_ddl_transaction! + + def change + add_column_with_default :spam_logs, :submitted_as_ham, :boolean, default: false + end +end diff --git a/db/migrate/20160802010328_remove_builds_enable_index_on_projects.rb b/db/migrate/20160802010328_remove_builds_enable_index_on_projects.rb new file mode 100644 index 00000000000..5fd51cb65f1 --- /dev/null +++ b/db/migrate/20160802010328_remove_builds_enable_index_on_projects.rb @@ -0,0 +1,9 @@ +class RemoveBuildsEnableIndexOnProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + remove_index :projects, column: :builds_enabled if index_exists?(:projects, :builds_enabled) + end +end diff --git a/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb new file mode 100644 index 00000000000..baf2e70b127 --- /dev/null +++ b/db/migrate/20160803161903_add_unique_index_to_lists_label_id.rb @@ -0,0 +1,15 @@ +class AddUniqueIndexToListsLabelId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :lists, [:board_id, :label_id], unique: true + end + + def down + remove_index :lists, column: [:board_id, :label_id] if index_exists?(:lists, [:board_id, :label_id], unique: true) + end +end diff --git a/db/migrate/20160804150737_add_timestamps_to_members_again.rb b/db/migrate/20160804150737_add_timestamps_to_members_again.rb new file mode 100644 index 00000000000..6691ba57fbb --- /dev/null +++ b/db/migrate/20160804150737_add_timestamps_to_members_again.rb @@ -0,0 +1,21 @@ +# rubocop:disable all +# 20141121133009_add_timestamps_to_members.rb was meant to ensure that all +# rows in the members table had created_at and updated_at set, following an +# error in a previous migration. This failed to set all rows in at least one +# case: https://gitlab.com/gitlab-org/gitlab-ce/issues/20568 +# +# Why this happened is lost in the mists of time, so repeat the SQL query +# without speculation, just in case more than one person was affected. +class AddTimestampsToMembersAgain < ActiveRecord::Migration + DOWNTIME = false + + def up + execute "UPDATE members SET created_at = NOW() WHERE created_at IS NULL" + execute "UPDATE members SET updated_at = NOW() WHERE updated_at IS NULL" + end + + def down + # no change + end + +end diff --git a/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb b/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb new file mode 100644 index 00000000000..a853de3abfb --- /dev/null +++ b/db/migrate/20160805041956_add_deleted_at_to_namespaces.rb @@ -0,0 +1,12 @@ +class AddDeletedAtToNamespaces < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def change + add_column :namespaces, :deleted_at, :datetime + add_concurrent_index :namespaces, :deleted_at + end +end diff --git a/db/migrate/20160808085531_add_token_to_build.rb b/db/migrate/20160808085531_add_token_to_build.rb new file mode 100644 index 00000000000..3ed2a103ae3 --- /dev/null +++ b/db/migrate/20160808085531_add_token_to_build.rb @@ -0,0 +1,10 @@ +class AddTokenToBuild < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :ci_builds, :token, :string + end +end diff --git a/db/migrate/20160808085602_add_index_for_build_token.rb b/db/migrate/20160808085602_add_index_for_build_token.rb new file mode 100644 index 00000000000..10ef42afce1 --- /dev/null +++ b/db/migrate/20160808085602_add_index_for_build_token.rb @@ -0,0 +1,12 @@ +class AddIndexForBuildToken < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def change + add_concurrent_index :ci_builds, :token, unique: true + end +end diff --git a/db/migrate/20160810102349_remove_ci_runner_trigram_indexes.rb b/db/migrate/20160810102349_remove_ci_runner_trigram_indexes.rb new file mode 100644 index 00000000000..0cfb637804b --- /dev/null +++ b/db/migrate/20160810102349_remove_ci_runner_trigram_indexes.rb @@ -0,0 +1,27 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveCiRunnerTrigramIndexes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + # Disabled for the "down" method so the indexes can be re-created concurrently. + disable_ddl_transaction! + + def up + return unless Gitlab::Database.postgresql? + + transaction do + execute 'DROP INDEX IF EXISTS index_ci_runners_on_token_trigram;' + execute 'DROP INDEX IF EXISTS index_ci_runners_on_description_trigram;' + end + end + + def down + return unless Gitlab::Database.postgresql? + + execute 'CREATE INDEX CONCURRENTLY index_ci_runners_on_token_trigram ON ci_runners USING gin(token gin_trgm_ops);' + execute 'CREATE INDEX CONCURRENTLY index_ci_runners_on_description_trigram ON ci_runners USING gin(description gin_trgm_ops);' + end +end diff --git a/db/migrate/20160810142633_remove_redundant_indexes.rb b/db/migrate/20160810142633_remove_redundant_indexes.rb new file mode 100644 index 00000000000..8641c6ffa8f --- /dev/null +++ b/db/migrate/20160810142633_remove_redundant_indexes.rb @@ -0,0 +1,112 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveRedundantIndexes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + indexes = [ + [:ci_taggings, 'ci_taggings_idx'], + [:audit_events, 'index_audit_events_on_author_id'], + [:audit_events, 'index_audit_events_on_type'], + [:ci_builds, 'index_ci_builds_on_erased_by_id'], + [:ci_builds, 'index_ci_builds_on_project_id_and_commit_id'], + [:ci_builds, 'index_ci_builds_on_type'], + [:ci_commits, 'index_ci_commits_on_project_id'], + [:ci_commits, 'index_ci_commits_on_project_id_and_committed_at'], + [:ci_commits, 'index_ci_commits_on_project_id_and_committed_at_and_id'], + [:ci_commits, 'index_ci_commits_on_project_id_and_sha'], + [:ci_commits, 'index_ci_commits_on_sha'], + [:ci_events, 'index_ci_events_on_created_at'], + [:ci_events, 'index_ci_events_on_is_admin'], + [:ci_events, 'index_ci_events_on_project_id'], + [:ci_jobs, 'index_ci_jobs_on_deleted_at'], + [:ci_jobs, 'index_ci_jobs_on_project_id'], + [:ci_projects, 'index_ci_projects_on_gitlab_id'], + [:ci_projects, 'index_ci_projects_on_shared_runners_enabled'], + [:ci_services, 'index_ci_services_on_project_id'], + [:ci_sessions, 'index_ci_sessions_on_session_id'], + [:ci_sessions, 'index_ci_sessions_on_updated_at'], + [:ci_tags, 'index_ci_tags_on_name'], + [:ci_triggers, 'index_ci_triggers_on_deleted_at'], + [:identities, 'index_identities_on_created_at_and_id'], + [:issues, 'index_issues_on_title'], + [:keys, 'index_keys_on_created_at_and_id'], + [:members, 'index_members_on_created_at_and_id'], + [:members, 'index_members_on_type'], + [:milestones, 'index_milestones_on_created_at_and_id'], + [:namespaces, 'index_namespaces_on_visibility_level'], + [:projects, 'index_projects_on_builds_enabled_and_shared_runners_enabled'], + [:services, 'index_services_on_category'], + [:services, 'index_services_on_created_at_and_id'], + [:services, 'index_services_on_default'], + [:snippets, 'index_snippets_on_created_at'], + [:snippets, 'index_snippets_on_created_at_and_id'], + [:todos, 'index_todos_on_state'], + [:web_hooks, 'index_web_hooks_on_created_at_and_id'], + + # These indexes _may_ be used but they can be replaced by other existing + # indexes. + + # There's already a composite index on (project_id, iid) which means that + # a separate index for _just_ project_id is not needed. + [:issues, 'index_issues_on_project_id'], + + # These are all composite indexes for the columns (created_at, id). In all + # these cases there's already a standalone index for "created_at" which + # can be used instead. + # + # Because the "id" column of these composite indexes is never needed (due + # to "id" already being indexed as its a primary key) these composite + # indexes are useless. + [:issues, 'index_issues_on_created_at_and_id'], + [:merge_requests, 'index_merge_requests_on_created_at_and_id'], + [:namespaces, 'index_namespaces_on_created_at_and_id'], + [:notes, 'index_notes_on_created_at_and_id'], + [:projects, 'index_projects_on_created_at_and_id'], + [:users, 'index_users_on_created_at_and_id'], + ] + + transaction do + indexes.each do |(table, index)| + remove_index(table, name: index) if index_exists_by_name?(table, index) + end + end + + add_concurrent_index(:users, :created_at) + add_concurrent_index(:projects, :created_at) + add_concurrent_index(:namespaces, :created_at) + end + + def down + # We're only restoring the composite indexes that could be replaced with + # individual ones, just in case somebody would ever want to revert. + transaction do + remove_index(:users, :created_at) + remove_index(:projects, :created_at) + remove_index(:namespaces, :created_at) + end + + [:issues, :merge_requests, :namespaces, :notes, :projects, :users].each do |table| + add_concurrent_index(table, [:created_at, :id], + name: "index_#{table}_on_created_at_and_id") + end + end + + # Rails' index_exists? doesn't work when you only give it a table and index + # name. As such we have to use some extra code to check if an index exists for + # a given name. + def index_exists_by_name?(table, index) + indexes_for_table[table].include?(index) + end + + def indexes_for_table + @indexes_for_table ||= Hash.new do |hash, table_name| + hash[table_name] = indexes(table_name).map(&:name) + end + end +end diff --git a/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb b/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb new file mode 100644 index 00000000000..7152bd04331 --- /dev/null +++ b/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddColumnNameToU2fRegistrations < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :u2f_registrations, :name, :string + end +end diff --git a/db/migrate/20160817133006_add_koding_to_application_settings.rb b/db/migrate/20160817133006_add_koding_to_application_settings.rb new file mode 100644 index 00000000000..915d3d78e40 --- /dev/null +++ b/db/migrate/20160817133006_add_koding_to_application_settings.rb @@ -0,0 +1,10 @@ +class AddKodingToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :koding_enabled, :boolean + add_column :application_settings, :koding_url, :string + end +end diff --git a/db/migrate/20160817154936_add_discussion_ids_to_notes.rb b/db/migrate/20160817154936_add_discussion_ids_to_notes.rb new file mode 100644 index 00000000000..61facce665a --- /dev/null +++ b/db/migrate/20160817154936_add_discussion_ids_to_notes.rb @@ -0,0 +1,13 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddDiscussionIdsToNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :notes, :discussion_id, :string + add_column :notes, :original_discussion_id, :string + end +end diff --git a/db/migrate/20160818205718_add_expires_at_to_project_group_links.rb b/db/migrate/20160818205718_add_expires_at_to_project_group_links.rb new file mode 100644 index 00000000000..0ed538b0df8 --- /dev/null +++ b/db/migrate/20160818205718_add_expires_at_to_project_group_links.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddExpiresAtToProjectGroupLinks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :project_group_links, :expires_at, :date + end +end diff --git a/db/migrate/20160819221631_add_index_to_note_discussion_id.rb b/db/migrate/20160819221631_add_index_to_note_discussion_id.rb new file mode 100644 index 00000000000..b6e8bb18e7b --- /dev/null +++ b/db/migrate/20160819221631_add_index_to_note_discussion_id.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexToNoteDiscussionId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def change + add_concurrent_index :notes, :discussion_id + end +end diff --git a/db/migrate/20160819221833_reset_diff_note_discussion_id_because_it_was_calculated_wrongly.rb b/db/migrate/20160819221833_reset_diff_note_discussion_id_because_it_was_calculated_wrongly.rb new file mode 100644 index 00000000000..0c68cf01900 --- /dev/null +++ b/db/migrate/20160819221833_reset_diff_note_discussion_id_because_it_was_calculated_wrongly.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 ResetDiffNoteDiscussionIdBecauseItWasCalculatedWrongly < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + execute "UPDATE notes SET discussion_id = NULL WHERE discussion_id IS NOT NULL AND type = 'DiffNote'" + end +end diff --git a/db/migrate/20160823081327_change_merge_error_to_text.rb b/db/migrate/20160823081327_change_merge_error_to_text.rb new file mode 100644 index 00000000000..7920389cd83 --- /dev/null +++ b/db/migrate/20160823081327_change_merge_error_to_text.rb @@ -0,0 +1,10 @@ +class ChangeMergeErrorToText < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = true + DOWNTIME_REASON = 'This migration requires downtime because it alters a column from varchar(255) to text.' + + def change + change_column :merge_requests, :merge_error, :text, limit: 65535 + end +end diff --git a/db/migrate/20160823213309_add_lfs_enabled_to_projects.rb b/db/migrate/20160823213309_add_lfs_enabled_to_projects.rb new file mode 100644 index 00000000000..c169084e976 --- /dev/null +++ b/db/migrate/20160823213309_add_lfs_enabled_to_projects.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLfsEnabledToProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :projects, :lfs_enabled, :boolean + end +end diff --git a/db/migrate/20160824103857_drop_unused_ci_tables.rb b/db/migrate/20160824103857_drop_unused_ci_tables.rb new file mode 100644 index 00000000000..65cf46308d9 --- /dev/null +++ b/db/migrate/20160824103857_drop_unused_ci_tables.rb @@ -0,0 +1,11 @@ +class DropUnusedCiTables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + drop_table(:ci_services) + drop_table(:ci_web_hooks) + end +end diff --git a/db/migrate/20160824124900_add_table_issue_metrics.rb b/db/migrate/20160824124900_add_table_issue_metrics.rb new file mode 100644 index 00000000000..e9bb79b3c62 --- /dev/null +++ b/db/migrate/20160824124900_add_table_issue_metrics.rb @@ -0,0 +1,37 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddTableIssueMetrics < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Adding foreign key' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + create_table :issue_metrics do |t| + t.references :issue, index: { name: "index_issue_metrics" }, foreign_key: { on_delete: :cascade }, null: false + + t.datetime 'first_mentioned_in_commit_at' + t.datetime 'first_associated_with_milestone_at' + t.datetime 'first_added_to_board_at' + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160825052008_add_table_merge_request_metrics.rb b/db/migrate/20160825052008_add_table_merge_request_metrics.rb new file mode 100644 index 00000000000..e01cc5038b9 --- /dev/null +++ b/db/migrate/20160825052008_add_table_merge_request_metrics.rb @@ -0,0 +1,38 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddTableMergeRequestMetrics < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Adding foreign key' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + create_table :merge_request_metrics do |t| + t.references :merge_request, index: { name: "index_merge_request_metrics" }, foreign_key: { on_delete: :cascade }, null: false + + t.datetime 'latest_build_started_at' + t.datetime 'latest_build_finished_at' + t.datetime 'first_deployed_to_production_at', index: true + t.datetime 'merged_at' + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160827011312_ensure_lock_version_has_no_default.rb b/db/migrate/20160827011312_ensure_lock_version_has_no_default.rb new file mode 100644 index 00000000000..7c55bc23cf2 --- /dev/null +++ b/db/migrate/20160827011312_ensure_lock_version_has_no_default.rb @@ -0,0 +1,16 @@ +class EnsureLockVersionHasNoDefault < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + change_column_default :issues, :lock_version, nil + change_column_default :merge_requests, :lock_version, nil + + execute('UPDATE issues SET lock_version = 1 WHERE lock_version = 0') + execute('UPDATE merge_requests SET lock_version = 1 WHERE lock_version = 0') + end + + def down + end +end diff --git a/db/migrate/20160829114652_add_markdown_cache_columns.rb b/db/migrate/20160829114652_add_markdown_cache_columns.rb new file mode 100644 index 00000000000..8753e55e058 --- /dev/null +++ b/db/migrate/20160829114652_add_markdown_cache_columns.rb @@ -0,0 +1,38 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddMarkdownCacheColumns < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + COLUMNS = { + abuse_reports: [:message], + appearances: [:description], + application_settings: [ + :sign_in_text, + :help_page_text, + :shared_runners_text, + :after_sign_up_text + ], + broadcast_messages: [:message], + issues: [:title, :description], + labels: [:description], + merge_requests: [:title, :description], + milestones: [:title, :description], + namespaces: [:description], + notes: [:note], + projects: [:description], + releases: [:description], + snippets: [:title, :content], + } + + def change + COLUMNS.each do |table, columns| + columns.each do |column| + add_column table, "#{column}_html", :text + end + end + end +end diff --git a/db/migrate/20160830203109_add_confidential_issues_events_to_web_hooks.rb b/db/migrate/20160830203109_add_confidential_issues_events_to_web_hooks.rb new file mode 100644 index 00000000000..a27947212f6 --- /dev/null +++ b/db/migrate/20160830203109_add_confidential_issues_events_to_web_hooks.rb @@ -0,0 +1,15 @@ +class AddConfidentialIssuesEventsToWebHooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :web_hooks, :confidential_issues_events, :boolean, default: false, allow_null: false + end + + def down + remove_column :web_hooks, :confidential_issues_events + end +end diff --git a/db/migrate/20160830211132_add_confidential_issues_events_to_services.rb b/db/migrate/20160830211132_add_confidential_issues_events_to_services.rb new file mode 100644 index 00000000000..030e7c39350 --- /dev/null +++ b/db/migrate/20160830211132_add_confidential_issues_events_to_services.rb @@ -0,0 +1,15 @@ +class AddConfidentialIssuesEventsToServices < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :services, :confidential_issues_events, :boolean, default: true, allow_null: false + end + + def down + remove_column :services, :confidential_issues_events + end +end diff --git a/db/migrate/20160830232601_change_lock_version_not_null.rb b/db/migrate/20160830232601_change_lock_version_not_null.rb new file mode 100644 index 00000000000..01c58ed5bdc --- /dev/null +++ b/db/migrate/20160830232601_change_lock_version_not_null.rb @@ -0,0 +1,13 @@ +class ChangeLockVersionNotNull < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + change_column_null :issues, :lock_version, true + change_column_null :merge_requests, :lock_version, true + end + + def down + end +end diff --git a/db/migrate/20160831214002_create_project_features.rb b/db/migrate/20160831214002_create_project_features.rb new file mode 100644 index 00000000000..2d76a015a08 --- /dev/null +++ b/db/migrate/20160831214002_create_project_features.rb @@ -0,0 +1,16 @@ +class CreateProjectFeatures < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :project_features do |t| + t.belongs_to :project, index: true + t.integer :merge_requests_access_level + t.integer :issues_access_level + t.integer :wiki_access_level + t.integer :snippets_access_level + t.integer :builds_access_level + + t.timestamps + end + end +end diff --git a/db/migrate/20160831214543_migrate_project_features.rb b/db/migrate/20160831214543_migrate_project_features.rb new file mode 100644 index 00000000000..93f9821bc76 --- /dev/null +++ b/db/migrate/20160831214543_migrate_project_features.rb @@ -0,0 +1,44 @@ +class MigrateProjectFeatures < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = true + DOWNTIME_REASON = + <<-EOT + Migrating issues_enabled, merge_requests_enabled, wiki_enabled, builds_enabled, snippets_enabled fields from projects to + a new table called project_features. + EOT + + def up + sql = + %Q{ + INSERT INTO project_features(project_id, issues_access_level, merge_requests_access_level, wiki_access_level, + builds_access_level, snippets_access_level, created_at, updated_at) + SELECT + id AS project_id, + CASE WHEN issues_enabled IS true THEN 20 ELSE 0 END AS issues_access_level, + CASE WHEN merge_requests_enabled IS true THEN 20 ELSE 0 END AS merge_requests_access_level, + CASE WHEN wiki_enabled IS true THEN 20 ELSE 0 END AS wiki_access_level, + CASE WHEN builds_enabled IS true THEN 20 ELSE 0 END AS builds_access_level, + CASE WHEN snippets_enabled IS true THEN 20 ELSE 0 END AS snippets_access_level, + created_at, + updated_at + FROM projects + } + + execute(sql) + end + + def down + sql = %Q{ + UPDATE projects + SET + issues_enabled = COALESCE((SELECT CASE WHEN issues_access_level = 20 THEN true ELSE false END AS issues_enabled FROM project_features WHERE project_features.project_id = projects.id), true), + merge_requests_enabled = COALESCE((SELECT CASE WHEN merge_requests_access_level = 20 THEN true ELSE false END AS merge_requests_enabled FROM project_features WHERE project_features.project_id = projects.id),true), + wiki_enabled = COALESCE((SELECT CASE WHEN wiki_access_level = 20 THEN true ELSE false END AS wiki_enabled FROM project_features WHERE project_features.project_id = projects.id), true), + builds_enabled = COALESCE((SELECT CASE WHEN builds_access_level = 20 THEN true ELSE false END AS builds_enabled FROM project_features WHERE project_features.project_id = projects.id), true), + snippets_enabled = COALESCE((SELECT CASE WHEN snippets_access_level = 20 THEN true ELSE false END AS snippets_enabled FROM project_features WHERE project_features.project_id = projects.id),true) + } + + execute(sql) + end +end diff --git a/db/migrate/20160831223750_remove_features_enabled_from_projects.rb b/db/migrate/20160831223750_remove_features_enabled_from_projects.rb new file mode 100644 index 00000000000..a2c207b49ea --- /dev/null +++ b/db/migrate/20160831223750_remove_features_enabled_from_projects.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveFeaturesEnabledFromProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + DOWNTIME_REASON = "Removing fields from database requires downtine." + + def up + remove_column :projects, :issues_enabled + remove_column :projects, :merge_requests_enabled + remove_column :projects, :builds_enabled + remove_column :projects, :wiki_enabled + remove_column :projects, :snippets_enabled + end + + # Ugly SQL but the only way i found to make it work on both Postgres and Mysql + # It will be slow but it is ok since it is a revert method + def down + add_column_with_default(:projects, :issues_enabled, :boolean, default: true, allow_null: false) + add_column_with_default(:projects, :merge_requests_enabled, :boolean, default: true, allow_null: false) + add_column_with_default(:projects, :builds_enabled, :boolean, default: true, allow_null: false) + add_column_with_default(:projects, :wiki_enabled, :boolean, default: true, allow_null: false) + add_column_with_default(:projects, :snippets_enabled, :boolean, default: true, allow_null: false) + end +end diff --git a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb new file mode 100644 index 00000000000..f1a1f001cb3 --- /dev/null +++ b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb @@ -0,0 +1,15 @@ +class SetConfidentialIssuesEventsOnWebhooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + update_column_in_batches(:web_hooks, :confidential_issues_events, true) do |table, query| + query.where(table[:issues_events].eq(true)) + end + end + + def down + # noop + end +end diff --git a/db/migrate/20160901213340_add_lfs_enabled_to_namespaces.rb b/db/migrate/20160901213340_add_lfs_enabled_to_namespaces.rb new file mode 100644 index 00000000000..fd413d1ca8c --- /dev/null +++ b/db/migrate/20160901213340_add_lfs_enabled_to_namespaces.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 AddLfsEnabledToNamespaces < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :namespaces, :lfs_enabled, :boolean + end +end diff --git a/db/migrate/20160902122721_drop_gitorious_field_from_application_settings.rb b/db/migrate/20160902122721_drop_gitorious_field_from_application_settings.rb new file mode 100644 index 00000000000..a80a57254dd --- /dev/null +++ b/db/migrate/20160902122721_drop_gitorious_field_from_application_settings.rb @@ -0,0 +1,39 @@ +class DropGitoriousFieldFromApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # After the deploy the caches will be cold anyway + DOWNTIME = false + + def up + require 'yaml' + + import_sources = connection.execute('SELECT import_sources FROM application_settings;') + return unless import_sources.first # support empty databases + + yaml = if Gitlab::Database.postgresql? + import_sources.values[0][0] + else + import_sources.first[0] + end + + yaml = YAML.safe_load(yaml) + yaml.delete 'gitorious' + + # No need for a WHERE clause as there is only one + connection.execute("UPDATE application_settings SET import_sources = #{update_yaml(yaml)}") + end + + def down + # noop, gitorious still yields a 404 anyway + end + + private + + def connection + ActiveRecord::Base.connection + end + + def update_yaml(yaml) + connection.quote(YAML.dump(yaml)) + end +end diff --git a/db/migrate/20160907131111_add_environment_type_to_environments.rb b/db/migrate/20160907131111_add_environment_type_to_environments.rb new file mode 100644 index 00000000000..fac73753d5b --- /dev/null +++ b/db/migrate/20160907131111_add_environment_type_to_environments.rb @@ -0,0 +1,9 @@ +class AddEnvironmentTypeToEnvironments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :environments, :environment_type, :string + end +end diff --git a/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb b/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb new file mode 100644 index 00000000000..18ea9d43a43 --- /dev/null +++ b/db/migrate/20160913162434_remove_projects_pushes_since_gc.rb @@ -0,0 +1,19 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveProjectsPushesSinceGc < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = true + DOWNTIME_REASON = 'This migration removes an existing column' + + disable_ddl_transaction! + + def up + remove_column :projects, :pushes_since_gc + end + + def down + add_column_with_default :projects, :pushes_since_gc, :integer, default: 0 + end +end diff --git a/db/migrate/20160913212128_change_artifacts_size_column.rb b/db/migrate/20160913212128_change_artifacts_size_column.rb new file mode 100644 index 00000000000..063bbca537c --- /dev/null +++ b/db/migrate/20160913212128_change_artifacts_size_column.rb @@ -0,0 +1,15 @@ +class ChangeArtifactsSizeColumn < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = true + + DOWNTIME_REASON = 'Changing an integer column size requires a full table rewrite.' + + def up + change_column :ci_builds, :artifacts_size, :integer, limit: 8 + end + + def down + # do nothing + end +end diff --git a/db/migrate/20160915042921_create_merge_requests_closing_issues.rb b/db/migrate/20160915042921_create_merge_requests_closing_issues.rb new file mode 100644 index 00000000000..94874a853da --- /dev/null +++ b/db/migrate/20160915042921_create_merge_requests_closing_issues.rb @@ -0,0 +1,34 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CreateMergeRequestsClosingIssues < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Adding foreign keys' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + create_table :merge_requests_closing_issues do |t| + t.references :merge_request, foreign_key: { on_delete: :cascade }, index: true, null: false + t.references :issue, foreign_key: { on_delete: :cascade }, index: true, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160920160832_add_index_to_labels_title.rb b/db/migrate/20160920160832_add_index_to_labels_title.rb new file mode 100644 index 00000000000..b5de552b98c --- /dev/null +++ b/db/migrate/20160920160832_add_index_to_labels_title.rb @@ -0,0 +1,11 @@ +class AddIndexToLabelsTitle < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def change + add_concurrent_index :labels, :title + end +end diff --git a/db/migrate/20160926145521_add_organization_to_user.rb b/db/migrate/20160926145521_add_organization_to_user.rb new file mode 100644 index 00000000000..e0bef6e7548 --- /dev/null +++ b/db/migrate/20160926145521_add_organization_to_user.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 AddOrganizationToUser < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :users, :organization, :string + end +end diff --git a/db/migrate/20161007133303_precalculate_trending_projects.rb b/db/migrate/20161007133303_precalculate_trending_projects.rb new file mode 100644 index 00000000000..b324cd94268 --- /dev/null +++ b/db/migrate/20161007133303_precalculate_trending_projects.rb @@ -0,0 +1,38 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class PrecalculateTrendingProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + create_table :trending_projects do |t| + t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: false + end + + timestamp = connection.quote(1.month.ago) + + # We're hardcoding the visibility level (public) here so that if it ever + # changes this query doesn't suddenly use the new value (which may break + # later migrations). + visibility = 20 + + execute <<-EOF.strip_heredoc + INSERT INTO trending_projects (project_id) + SELECT project_id + FROM notes + INNER JOIN projects ON projects.id = notes.project_id + WHERE notes.created_at >= #{timestamp} + AND notes.system IS FALSE + AND projects.visibility_level = #{visibility} + GROUP BY project_id + ORDER BY count(*) DESC + LIMIT 100; + EOF + end + + def down + drop_table :trending_projects + end +end diff --git a/db/schema.rb b/db/schema.rb index 102e4c0fdeb..657ffa5216c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160726093600) do +ActiveRecord::Schema.define(version: 20161007133303) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -23,6 +23,7 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.text "message" t.datetime "created_at" t.datetime "updated_at" + t.text "message_html" end create_table "appearances", force: :cascade do |t| @@ -30,8 +31,9 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.text "description" t.string "header_logo" t.string "logo" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description_html" end create_table "application_settings", force: :cascade do |t| @@ -84,12 +86,18 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "health_check_access_token" t.boolean "send_user_confirmation_email", default: false t.integer "container_registry_token_expire_delay", default: 5 - t.boolean "user_default_external", default: false, null: false t.text "after_sign_up_text" + t.boolean "user_default_external", default: false, null: false t.string "repository_storage", default: "default" t.string "enabled_git_access_protocol" t.boolean "domain_blacklist_enabled", default: false t.text "domain_blacklist" + t.boolean "koding_enabled" + t.string "koding_url" + t.text "sign_in_text_html" + t.text "help_page_text_html" + t.text "shared_runners_text_html" + t.text "after_sign_up_text_html" end create_table "audit_events", force: :cascade do |t| @@ -102,9 +110,7 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "updated_at" end - add_index "audit_events", ["author_id"], name: "index_audit_events_on_author_id", using: :btree add_index "audit_events", ["entity_id", "entity_type"], name: "index_audit_events_on_entity_id_and_entity_type", using: :btree - add_index "audit_events", ["type"], name: "index_audit_events_on_type", using: :btree create_table "award_emoji", force: :cascade do |t| t.string "name" @@ -119,14 +125,23 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree + create_table "boards", force: :cascade do |t| + t.integer "project_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "boards", ["project_id"], name: "index_boards_on_project_id", using: :btree + create_table "broadcast_messages", force: :cascade do |t| - t.text "message", null: false + t.text "message", null: false t.datetime "starts_at" t.datetime "ends_at" t.datetime "created_at" t.datetime "updated_at" t.string "color" t.string "font" + t.text "message_html" end create_table "ci_application_settings", force: :cascade do |t| @@ -150,9 +165,9 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.text "commands" t.integer "job_id" t.string "name" - t.boolean "deploy", default: false + t.boolean "deploy", default: false t.text "options" - t.boolean "allow_failure", default: false, null: false + t.boolean "allow_failure", default: false, null: false t.string "stage" t.integer "trigger_request_id" t.integer "stage_idx" @@ -169,9 +184,11 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "erased_at" t.datetime "artifacts_expire_at" t.string "environment" - t.integer "artifacts_size" + t.integer "artifacts_size", limit: 8 t.string "when" t.text "yaml_variables" + t.datetime "queued_at" + t.string "token" end add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -179,13 +196,11 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "ci_builds", ["commit_id", "type", "name", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_name_and_ref", using: :btree add_index "ci_builds", ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree add_index "ci_builds", ["commit_id"], name: "index_ci_builds_on_commit_id", using: :btree - add_index "ci_builds", ["erased_by_id"], name: "index_ci_builds_on_erased_by_id", using: :btree add_index "ci_builds", ["gl_project_id"], name: "index_ci_builds_on_gl_project_id", using: :btree - add_index "ci_builds", ["project_id", "commit_id"], name: "index_ci_builds_on_project_id_and_commit_id", using: :btree add_index "ci_builds", ["project_id"], name: "index_ci_builds_on_project_id", using: :btree add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree add_index "ci_builds", ["status"], name: "index_ci_builds_on_status", using: :btree - add_index "ci_builds", ["type"], name: "index_ci_builds_on_type", using: :btree + add_index "ci_builds", ["token"], name: "index_ci_builds_on_token", unique: true, using: :btree create_table "ci_commits", force: :cascade do |t| t.integer "project_id" @@ -209,11 +224,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "ci_commits", ["gl_project_id", "sha"], name: "index_ci_commits_on_gl_project_id_and_sha", using: :btree add_index "ci_commits", ["gl_project_id", "status"], name: "index_ci_commits_on_gl_project_id_and_status", using: :btree add_index "ci_commits", ["gl_project_id"], name: "index_ci_commits_on_gl_project_id", using: :btree - add_index "ci_commits", ["project_id", "committed_at", "id"], name: "index_ci_commits_on_project_id_and_committed_at_and_id", using: :btree - add_index "ci_commits", ["project_id", "committed_at"], name: "index_ci_commits_on_project_id_and_committed_at", using: :btree - add_index "ci_commits", ["project_id", "sha"], name: "index_ci_commits_on_project_id_and_sha", using: :btree - add_index "ci_commits", ["project_id"], name: "index_ci_commits_on_project_id", using: :btree - add_index "ci_commits", ["sha"], name: "index_ci_commits_on_sha", using: :btree add_index "ci_commits", ["status"], name: "index_ci_commits_on_status", using: :btree add_index "ci_commits", ["user_id"], name: "index_ci_commits_on_user_id", using: :btree @@ -226,10 +236,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "updated_at" end - add_index "ci_events", ["created_at"], name: "index_ci_events_on_created_at", using: :btree - add_index "ci_events", ["is_admin"], name: "index_ci_events_on_is_admin", using: :btree - add_index "ci_events", ["project_id"], name: "index_ci_events_on_project_id", using: :btree - create_table "ci_jobs", force: :cascade do |t| t.integer "project_id", null: false t.text "commands" @@ -244,9 +250,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "deleted_at" end - add_index "ci_jobs", ["deleted_at"], name: "index_ci_jobs_on_deleted_at", using: :btree - add_index "ci_jobs", ["project_id"], name: "index_ci_jobs_on_project_id", using: :btree - create_table "ci_projects", force: :cascade do |t| t.string "name" t.integer "timeout", default: 3600, null: false @@ -270,9 +273,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.text "generated_yaml_config" end - add_index "ci_projects", ["gitlab_id"], name: "index_ci_projects_on_gitlab_id", using: :btree - add_index "ci_projects", ["shared_runners_enabled"], name: "index_ci_projects_on_shared_runners_enabled", using: :btree - create_table "ci_runner_projects", force: :cascade do |t| t.integer "runner_id", null: false t.integer "project_id" @@ -301,22 +301,8 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.boolean "locked", default: false, null: false end - add_index "ci_runners", ["description"], name: "index_ci_runners_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "ci_runners", ["locked"], name: "index_ci_runners_on_locked", using: :btree add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree - add_index "ci_runners", ["token"], name: "index_ci_runners_on_token_trigram", using: :gin, opclasses: {"token"=>"gin_trgm_ops"} - - create_table "ci_services", force: :cascade do |t| - t.string "type" - t.string "title" - t.integer "project_id", null: false - t.datetime "created_at" - t.datetime "updated_at" - t.boolean "active", default: false, null: false - t.text "properties" - end - - add_index "ci_services", ["project_id"], name: "index_ci_services_on_project_id", using: :btree create_table "ci_sessions", force: :cascade do |t| t.string "session_id", null: false @@ -325,9 +311,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "updated_at" end - add_index "ci_sessions", ["session_id"], name: "index_ci_sessions_on_session_id", using: :btree - add_index "ci_sessions", ["updated_at"], name: "index_ci_sessions_on_updated_at", using: :btree - create_table "ci_taggings", force: :cascade do |t| t.integer "tag_id" t.integer "taggable_id" @@ -338,7 +321,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "created_at" end - add_index "ci_taggings", ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "ci_taggings_idx", unique: true, using: :btree add_index "ci_taggings", ["taggable_id", "taggable_type", "context"], name: "index_ci_taggings_on_taggable_id_and_taggable_type_and_context", using: :btree create_table "ci_tags", force: :cascade do |t| @@ -346,8 +328,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "taggings_count", default: 0 end - add_index "ci_tags", ["name"], name: "index_ci_tags_on_name", unique: true, using: :btree - create_table "ci_trigger_requests", force: :cascade do |t| t.integer "trigger_id", null: false t.text "variables" @@ -365,7 +345,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "gl_project_id" end - add_index "ci_triggers", ["deleted_at"], name: "index_ci_triggers_on_deleted_at", using: :btree add_index "ci_triggers", ["gl_project_id"], name: "index_ci_triggers_on_gl_project_id", using: :btree create_table "ci_variables", force: :cascade do |t| @@ -380,13 +359,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "ci_variables", ["gl_project_id"], name: "index_ci_variables_on_gl_project_id", using: :btree - create_table "ci_web_hooks", force: :cascade do |t| - t.string "url", null: false - t.integer "project_id", null: false - t.datetime "created_at" - t.datetime "updated_at" - end - create_table "deploy_keys_projects", force: :cascade do |t| t.integer "deploy_key_id", null: false t.integer "project_id", null: false @@ -427,10 +399,11 @@ ActiveRecord::Schema.define(version: 20160726093600) do create_table "environments", force: :cascade do |t| t.integer "project_id" - t.string "name", null: false + t.string "name", null: false t.datetime "created_at" t.datetime "updated_at" t.string "external_url" + t.string "environment_type" end add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", using: :btree @@ -471,9 +444,19 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "updated_at" end - add_index "identities", ["created_at", "id"], name: "index_identities_on_created_at_and_id", using: :btree add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "issue_metrics", force: :cascade do |t| + t.integer "issue_id", null: false + t.datetime "first_mentioned_in_commit_at" + t.datetime "first_associated_with_milestone_at" + t.datetime "first_added_to_board_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "issue_metrics", ["issue_id"], name: "index_issue_metrics", using: :btree + create_table "issues", force: :cascade do |t| t.string "title" t.integer "assignee_id" @@ -481,32 +464,32 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "project_id" t.datetime "created_at" t.datetime "updated_at" - t.integer "position", default: 0 + t.integer "position", default: 0 t.string "branch_name" t.text "description" t.integer "milestone_id" t.string "state" t.integer "iid" t.integer "updated_by_id" - t.boolean "confidential", default: false + t.boolean "confidential", default: false t.datetime "deleted_at" t.date "due_date" t.integer "moved_to_id" + t.integer "lock_version" + t.text "title_html" + t.text "description_html" end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree add_index "issues", ["author_id"], name: "index_issues_on_author_id", using: :btree add_index "issues", ["confidential"], name: "index_issues_on_confidential", using: :btree - add_index "issues", ["created_at", "id"], name: "index_issues_on_created_at_and_id", using: :btree add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree add_index "issues", ["deleted_at"], name: "index_issues_on_deleted_at", using: :btree add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "issues", ["due_date"], name: "index_issues_on_due_date", using: :btree add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree - add_index "issues", ["project_id"], name: "index_issues_on_project_id", using: :btree add_index "issues", ["state"], name: "index_issues_on_state", using: :btree - add_index "issues", ["title"], name: "index_issues_on_title", using: :btree add_index "issues", ["title"], name: "index_issues_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} create_table "keys", force: :cascade do |t| @@ -520,7 +503,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.boolean "public", default: false, null: false end - add_index "keys", ["created_at", "id"], name: "index_keys_on_created_at_and_id", using: :btree add_index "keys", ["fingerprint"], name: "index_keys_on_fingerprint", unique: true, using: :btree add_index "keys", ["user_id"], name: "index_keys_on_user_id", using: :btree @@ -541,13 +523,15 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "project_id" t.datetime "created_at" t.datetime "updated_at" - t.boolean "template", default: false + t.boolean "template", default: false t.string "description" t.integer "priority" + t.text "description_html" end add_index "labels", ["priority"], name: "index_labels_on_priority", using: :btree add_index "labels", ["project_id"], name: "index_labels_on_project_id", using: :btree + add_index "labels", ["title"], name: "index_labels_on_title", using: :btree create_table "lfs_objects", force: :cascade do |t| t.string "oid", null: false @@ -568,6 +552,19 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "lfs_objects_projects", ["project_id"], name: "index_lfs_objects_projects_on_project_id", using: :btree + create_table "lists", force: :cascade do |t| + t.integer "board_id", null: false + t.integer "label_id" + t.integer "list_type", default: 1, null: false + t.integer "position" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree + add_index "lists", ["board_id"], name: "index_lists_on_board_id", using: :btree + add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree + create_table "members", force: :cascade do |t| t.integer "access_level", null: false t.integer "source_id", null: false @@ -582,14 +579,13 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "invite_token" t.datetime "invite_accepted_at" t.datetime "requested_at" + t.date "expires_at" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree - add_index "members", ["created_at", "id"], name: "index_members_on_created_at_and_id", using: :btree add_index "members", ["invite_token"], name: "index_members_on_invite_token", unique: true, using: :btree add_index "members", ["requested_at"], name: "index_members_on_requested_at", using: :btree add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree - add_index "members", ["type"], name: "index_members_on_type", using: :btree add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree create_table "merge_request_diffs", force: :cascade do |t| @@ -605,7 +601,20 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "start_commit_sha" end - add_index "merge_request_diffs", ["merge_request_id"], name: "index_merge_request_diffs_on_merge_request_id", unique: true, using: :btree + add_index "merge_request_diffs", ["merge_request_id"], name: "index_merge_request_diffs_on_merge_request_id", using: :btree + + create_table "merge_request_metrics", force: :cascade do |t| + t.integer "merge_request_id", null: false + t.datetime "latest_build_started_at" + t.datetime "latest_build_finished_at" + t.datetime "first_deployed_to_production_at" + t.datetime "merged_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "merge_request_metrics", ["first_deployed_to_production_at"], name: "index_merge_request_metrics_on_first_deployed_to_production_at", using: :btree + add_index "merge_request_metrics", ["merge_request_id"], name: "index_merge_request_metrics", using: :btree create_table "merge_requests", force: :cascade do |t| t.string "target_branch", null: false @@ -625,19 +634,21 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "position", default: 0 t.datetime "locked_at" t.integer "updated_by_id" - t.string "merge_error" + t.text "merge_error" t.boolean "merge_when_build_succeeds", default: false, null: false t.integer "merge_user_id" t.string "merge_commit_sha" t.datetime "deleted_at" t.string "in_progress_merge_commit_sha" + t.integer "lock_version" + t.text "title_html" + t.text "description_html" t.boolean "remove_source_branch", default: true, null: false t.text "commit_message" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree add_index "merge_requests", ["author_id"], name: "index_merge_requests_on_author_id", using: :btree - add_index "merge_requests", ["created_at", "id"], name: "index_merge_requests_on_created_at_and_id", using: :btree add_index "merge_requests", ["created_at"], name: "index_merge_requests_on_created_at", using: :btree add_index "merge_requests", ["deleted_at"], name: "index_merge_requests_on_deleted_at", using: :btree add_index "merge_requests", ["description"], name: "index_merge_requests_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} @@ -649,18 +660,29 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "merge_requests", ["title"], name: "index_merge_requests_on_title", using: :btree add_index "merge_requests", ["title"], name: "index_merge_requests_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} + create_table "merge_requests_closing_issues", force: :cascade do |t| + t.integer "merge_request_id", null: false + t.integer "issue_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "merge_requests_closing_issues", ["issue_id"], name: "index_merge_requests_closing_issues_on_issue_id", using: :btree + add_index "merge_requests_closing_issues", ["merge_request_id"], name: "index_merge_requests_closing_issues_on_merge_request_id", using: :btree + create_table "milestones", force: :cascade do |t| - t.string "title", null: false - t.integer "project_id", null: false + t.string "title", null: false + t.integer "project_id", null: false t.text "description" t.date "due_date" t.datetime "created_at" t.datetime "updated_at" t.string "state" t.integer "iid" + t.text "title_html" + t.text "description_html" end - add_index "milestones", ["created_at", "id"], name: "index_milestones_on_created_at_and_id", using: :btree add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree @@ -680,16 +702,19 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.boolean "share_with_group_lock", default: false t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: true, null: false + t.datetime "deleted_at" + t.boolean "lfs_enabled" + t.text "description_html" end - add_index "namespaces", ["created_at", "id"], name: "index_namespaces_on_created_at_and_id", using: :btree + add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree + add_index "namespaces", ["deleted_at"], name: "index_namespaces_on_deleted_at", using: :btree add_index "namespaces", ["name"], name: "index_namespaces_on_name", unique: true, using: :btree add_index "namespaces", ["name"], name: "index_namespaces_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "namespaces", ["owner_id"], name: "index_namespaces_on_owner_id", using: :btree add_index "namespaces", ["path"], name: "index_namespaces_on_path", unique: true, using: :btree add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree - add_index "namespaces", ["visibility_level"], name: "index_namespaces_on_visibility_level", using: :btree create_table "notes", force: :cascade do |t| t.text "note" @@ -702,18 +727,23 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "line_code" t.string "commit_id" t.integer "noteable_id" - t.boolean "system", default: false, null: false + t.boolean "system", default: false, null: false t.text "st_diff" t.integer "updated_by_id" t.string "type" t.text "position" t.text "original_position" + t.datetime "resolved_at" + t.integer "resolved_by_id" + t.string "discussion_id" + t.string "original_discussion_id" + t.text "note_html" end add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree add_index "notes", ["commit_id"], name: "index_notes_on_commit_id", using: :btree - add_index "notes", ["created_at", "id"], name: "index_notes_on_created_at_and_id", using: :btree add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree + add_index "notes", ["discussion_id"], name: "index_notes_on_discussion_id", using: :btree add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"} add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree @@ -792,12 +822,26 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree add_index "personal_access_tokens", ["user_id"], name: "index_personal_access_tokens_on_user_id", using: :btree + create_table "project_features", force: :cascade do |t| + t.integer "project_id" + t.integer "merge_requests_access_level" + t.integer "issues_access_level" + t.integer "wiki_access_level" + t.integer "snippets_access_level" + t.integer "builds_access_level" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "project_features", ["project_id"], name: "index_project_features_on_project_id", using: :btree + create_table "project_group_links", force: :cascade do |t| t.integer "project_id", null: false t.integer "group_id", null: false t.datetime "created_at" t.datetime "updated_at" t.integer "group_access", default: 30, null: false + t.date "expires_at" end create_table "project_import_data", force: :cascade do |t| @@ -815,11 +859,7 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "created_at" t.datetime "updated_at" t.integer "creator_id" - t.boolean "issues_enabled", default: true, null: false - t.boolean "merge_requests_enabled", default: true, null: false - t.boolean "wiki_enabled", default: true, null: false t.integer "namespace_id" - t.boolean "snippets_enabled", default: true, null: false t.datetime "last_activity_at" t.string "import_url" t.integer "visibility_level", default: 0, null: false @@ -833,7 +873,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "commit_count", default: 0 t.text "import_error" t.integer "ci_id" - t.boolean "builds_enabled", default: true, null: false t.boolean "shared_runners_enabled", default: true, null: false t.string "runners_token" t.string "build_coverage_regex" @@ -841,7 +880,6 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "build_timeout", default: 3600, null: false t.boolean "pending_delete", default: false t.boolean "public_builds", default: true, null: false - t.integer "pushes_since_gc", default: 0 t.boolean "last_repository_check_failed" t.datetime "last_repository_check_at" t.boolean "container_registry_enabled" @@ -850,12 +888,12 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "repository_storage", default: "default", null: false t.boolean "request_access_enabled", default: true, null: false t.boolean "has_external_wiki" + t.boolean "lfs_enabled" + t.text "description_html" end - add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree - add_index "projects", ["builds_enabled"], name: "index_projects_on_builds_enabled", using: :btree add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree - add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree + add_index "projects", ["created_at"], name: "index_projects_on_created_at", using: :btree add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree @@ -902,6 +940,7 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "project_id" t.datetime "created_at" t.datetime "updated_at" + t.text "description_html" end add_index "releases", ["project_id", "tag"], name: "index_releases_on_project_id_and_tag", using: :btree @@ -927,23 +966,22 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "project_id" t.datetime "created_at" t.datetime "updated_at" - t.boolean "active", default: false, null: false + t.boolean "active", default: false, null: false t.text "properties" - t.boolean "template", default: false - t.boolean "push_events", default: true - t.boolean "issues_events", default: true - t.boolean "merge_requests_events", default: true - t.boolean "tag_push_events", default: true - t.boolean "note_events", default: true, null: false - t.boolean "build_events", default: false, null: false - t.string "category", default: "common", null: false - t.boolean "default", default: false - t.boolean "wiki_page_events", default: true - end - - add_index "services", ["category"], name: "index_services_on_category", using: :btree - add_index "services", ["created_at", "id"], name: "index_services_on_created_at_and_id", using: :btree - add_index "services", ["default"], name: "index_services_on_default", using: :btree + t.boolean "template", default: false + t.boolean "push_events", default: true + t.boolean "issues_events", default: true + t.boolean "merge_requests_events", default: true + t.boolean "tag_push_events", default: true + t.boolean "note_events", default: true, null: false + t.boolean "build_events", default: false, null: false + t.string "category", default: "common", null: false + t.boolean "default", default: false + t.boolean "wiki_page_events", default: true + t.boolean "pipeline_events", default: false, null: false + t.boolean "confidential_issues_events", default: true, null: false + end + add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree add_index "services", ["template"], name: "index_services_on_template", using: :btree @@ -957,11 +995,11 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "file_name" t.string "type" t.integer "visibility_level", default: 0, null: false + t.text "title_html" + t.text "content_html" end add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree - add_index "snippets", ["created_at", "id"], name: "index_snippets_on_created_at_and_id", using: :btree - add_index "snippets", ["created_at"], name: "index_snippets_on_created_at", using: :btree add_index "snippets", ["file_name"], name: "index_snippets_on_file_name_trigram", using: :gin, opclasses: {"file_name"=>"gin_trgm_ops"} add_index "snippets", ["project_id"], name: "index_snippets_on_project_id", using: :btree add_index "snippets", ["title"], name: "index_snippets_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} @@ -973,12 +1011,12 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.string "source_ip" t.string "user_agent" t.boolean "via_api" - t.integer "project_id" t.string "noteable_type" t.string "title" t.text "description" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "submitted_as_ham", default: false, null: false end create_table "subscriptions", force: :cascade do |t| @@ -1030,10 +1068,15 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree - add_index "todos", ["state"], name: "index_todos_on_state", using: :btree add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree add_index "todos", ["user_id"], name: "index_todos_on_user_id", using: :btree + create_table "trending_projects", force: :cascade do |t| + t.integer "project_id", null: false + end + + add_index "trending_projects", ["project_id"], name: "index_trending_projects_on_project_id", using: :btree + create_table "u2f_registrations", force: :cascade do |t| t.text "certificate" t.string "key_handle" @@ -1042,11 +1085,22 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.integer "user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" end add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree + create_table "user_agent_details", force: :cascade do |t| + t.string "user_agent", null: false + t.string "ip_address", null: false + t.integer "subject_id", null: false + t.string "subject_type", null: false + t.boolean "submitted", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -1105,12 +1159,13 @@ ActiveRecord::Schema.define(version: 20160726093600) do t.datetime "otp_grace_period_started_at" t.boolean "ldap_email", default: false, null: false t.boolean "external", default: false + t.string "organization" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree add_index "users", ["authentication_token"], name: "index_users_on_authentication_token", unique: true, using: :btree add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree - add_index "users", ["created_at", "id"], name: "index_users_on_created_at_and_id", using: :btree + add_index "users", ["created_at"], name: "index_users_on_created_at", using: :btree add_index "users", ["current_sign_in_at"], name: "index_users_on_current_sign_in_at", using: :btree add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree add_index "users", ["email"], name: "index_users_on_email_trigram", using: :gin, opclasses: {"email"=>"gin_trgm_ops"} @@ -1133,28 +1188,37 @@ ActiveRecord::Schema.define(version: 20160726093600) do add_index "users_star_projects", ["user_id"], name: "index_users_star_projects_on_user_id", using: :btree create_table "web_hooks", force: :cascade do |t| - t.string "url", limit: 2000 + t.string "url", limit: 2000 t.integer "project_id" t.datetime "created_at" t.datetime "updated_at" - t.string "type", default: "ProjectHook" + t.string "type", default: "ProjectHook" t.integer "service_id" - t.boolean "push_events", default: true, null: false - t.boolean "issues_events", default: false, null: false - t.boolean "merge_requests_events", default: false, null: false - t.boolean "tag_push_events", default: false - t.boolean "note_events", default: false, null: false - t.boolean "enable_ssl_verification", default: true - t.boolean "build_events", default: false, null: false - t.boolean "wiki_page_events", default: false, null: false + t.boolean "push_events", default: true, null: false + t.boolean "issues_events", default: false, null: false + t.boolean "merge_requests_events", default: false, null: false + t.boolean "tag_push_events", default: false + t.boolean "note_events", default: false, null: false + t.boolean "enable_ssl_verification", default: true + t.boolean "build_events", default: false, null: false + t.boolean "wiki_page_events", default: false, null: false t.string "token" + t.boolean "pipeline_events", default: false, null: false + t.boolean "confidential_issues_events", default: false, null: false end - add_index "web_hooks", ["created_at", "id"], name: "index_web_hooks_on_created_at_and_id", using: :btree add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree + add_foreign_key "boards", "projects" + add_foreign_key "issue_metrics", "issues", on_delete: :cascade + add_foreign_key "lists", "boards" + add_foreign_key "lists", "labels" + add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade + add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade + add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade add_foreign_key "personal_access_tokens", "users" add_foreign_key "protected_branch_merge_access_levels", "protected_branches" add_foreign_key "protected_branch_push_access_levels", "protected_branches" + add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" end diff --git a/doc/README.md b/doc/README.md index fc51ea911b9..7e3d9b00900 100644 --- a/doc/README.md +++ b/doc/README.md @@ -2,11 +2,12 @@ ## User documentation +- [Account Security](user/account/security.md) Securing your account via two-factor authentication, etc. - [API](api/README.md) Automate GitLab via a simple and powerful API. - [CI/CD](ci/README.md) GitLab Continuous Integration (CI) and Continuous Delivery (CD) getting started, `.gitlab-ci.yml` options, and examples. - [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab. -- [Container Registry](container_registry/README.md) Learn how to use GitLab Container Registry. -- [GitLab Basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. +- [Container Registry](user/project/container_registry.md) Learn how to use GitLab Container Registry. +- [GitLab basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. - [Importing to GitLab](workflow/importing/README.md). - [Importing and exporting projects between instances](user/project/settings/import_export.md). - [Markdown](user/markdown.md) GitLab's advanced formatting system. @@ -18,6 +19,8 @@ - [SSH](ssh/README.md) Setup your ssh keys and deploy keys for secure access to your projects. - [Webhooks](web_hooks/web_hooks.md) Let GitLab notify you when new code has been pushed to your project. - [Workflow](workflow/README.md) Using GitLab functionality and importing projects from GitHub and SVN. +- [University](university/README.md) Learn Git and GitLab through videos and courses. +- [Git Attributes](user/project/git_attributes.md) Managing Git attributes using a `.gitattributes` file. ## Administrator documentation @@ -28,11 +31,12 @@ - [Install](install/README.md) Requirements, directory structures and installation from source. - [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components. - [Integration](integration/README.md) How to integrate with systems such as JIRA, Redmine, Twitter. -- [Issue closing](customization/issue_closing.md) Customize how to close an issue from commit messages. -- [Libravatar](customization/libravatar.md) Use Libravatar for user avatars. +- [Issue closing pattern](administration/issue_closing_pattern.md) Customize how to close an issue from commit messages. +- [Koding](administration/integration/koding.md) Set up Koding to use with GitLab. +- [Libravatar](customization/libravatar.md) Use Libravatar instead of Gravatar for user avatars. - [Log system](administration/logs.md) Log system. - [Environment Variables](administration/environment_variables.md) to configure GitLab. -- [Operations](operations/README.md) Keeping GitLab up and running. +- [Operations](administration/operations.md) Keeping GitLab up and running. - [Raketasks](raketasks/README.md) Backups, maintenance, automatic webhook setup and the importing of projects. - [Repository checks](administration/repository_checks.md) Periodic Git repository checks. - [Repository storages](administration/repository_storages.md) Manage the paths used to store repositories. @@ -40,12 +44,12 @@ - [System hooks](system_hooks/system_hooks.md) Notifications when users, projects and keys are changed. - [Update](update/README.md) Update guides to upgrade your installation. - [Welcome message](customization/welcome_message.md) Add a custom welcome message to the sign-in page. -- [Reply by email](incoming_email/README.md) Allow users to comment on issues and merge requests by replying to notification emails. +- [Reply by email](administration/reply_by_email.md) Allow users to comment on issues and merge requests by replying to notification emails. - [Migrate GitLab CI to CE/EE](migrate_ci_to_ce/README.md) Follow this guide to migrate your existing GitLab CI data to GitLab CE/EE. - [Git LFS configuration](workflow/lfs/lfs_administration.md) - [Housekeeping](administration/housekeeping.md) Keep your Git repository tidy and fast. -- [GitLab Performance Monitoring](monitoring/performance/introduction.md) Configure GitLab and InfluxDB for measuring performance metrics. -- [Monitoring uptime](monitoring/health_check.md) Check the server status using the health check endpoint. +- [GitLab Performance Monitoring](administration/monitoring/performance/introduction.md) Configure GitLab and InfluxDB for measuring performance metrics. +- [Monitoring uptime](user/admin_area/monitoring/health_check.md) Check the server status using the health check endpoint. - [Debugging Tips](administration/troubleshooting/debug.md) Tips to debug problems when things go wrong - [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs. - [High Availability](administration/high_availability/README.md) Configure multiple servers for scaling or high availability. diff --git a/doc/administration/auth/ldap.md b/doc/administration/auth/ldap.md index 7186f707ad6..bf7814875bf 100644 --- a/doc/administration/auth/ldap.md +++ b/doc/administration/auth/ldap.md @@ -275,3 +275,9 @@ If you are getting 'Connection Refused' errors when trying to connect to the LDAP server please double-check the LDAP `port` and `method` settings used by GitLab. Common combinations are `method: 'plain'` and `port: 389`, OR `method: 'ssl'` and `port: 636`. + +### Login with valid credentials rejected + +If there is an unexpected error while authenticating the user with the LDAP +backend, the login is rejected and details about the error are logged to +`production.log`. diff --git a/doc/administration/build_artifacts.md b/doc/administration/build_artifacts.md new file mode 100644 index 00000000000..64353f7282b --- /dev/null +++ b/doc/administration/build_artifacts.md @@ -0,0 +1,90 @@ +# Build artifacts administration + +>**Notes:** +>- Introduced in GitLab 8.2 and GitLab Runner 0.7.0. +>- Starting from GitLab 8.4 and GitLab Runner 1.0, the artifacts archive format + changed to `ZIP`. +>- This is the administration documentation. For the user guide see + [user/project/builds/artifacts.md](../user/project/builds/artifacts.md). + +Artifacts is a list of files and directories which are attached to a build +after it completes successfully. This feature is enabled by default in all +GitLab installations. Keep reading if you want to know how to disable it. + +## Disabling build artifacts + +To disable artifacts site-wide, follow the steps below. + +--- + +**In Omnibus installations:** + +1. Edit `/etc/gitlab/gitlab.rb` and add the following line: + + ```ruby + gitlab_rails['artifacts_enabled'] = false + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +--- + +**In installations from source:** + +1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: + + ```yaml + artifacts: + enabled: false + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + +## Storing build artifacts + +After a successful build, GitLab Runner uploads an archive containing the build +artifacts to GitLab. + +To change the location where the artifacts are stored, follow the steps below. + +--- + +**In Omnibus installations:** + +_The artifacts are stored by default in +`/var/opt/gitlab/gitlab-rails/shared/artifacts`._ + +1. To change the storage path for example to `/mnt/storage/artifacts`, edit + `/etc/gitlab/gitlab.rb` and add the following line: + + ```ruby + gitlab_rails['artifacts_path'] = "/mnt/storage/artifacts" + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +--- + +**In installations from source:** + +_The artifacts are stored by default in +`/home/git/gitlab/shared/artifacts`._ + +1. To change the storage path for example to `/mnt/storage/artifacts`, edit + `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: + + ```yaml + artifacts: + enabled: true + path: /mnt/storage/artifacts + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + +## Set the maximum file size of the artifacts + +Provided the artifacts are enabled, you can change the maximum file size of the +artifacts through the [Admin area settings](../user/admin_area/settings/continuous_integration#maximum-artifacts-size). + +[reconfigure gitlab]: restart_gitlab.md "How to restart GitLab" +[restart gitlab]: restart_gitlab.md "How to restart GitLab" diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md index d5d43303454..d7cfb464f74 100644 --- a/doc/administration/container_registry.md +++ b/doc/administration/container_registry.md @@ -1,43 +1,32 @@ -# GitLab Container Registry Administration +# GitLab Container Registry administration -> **Note:** -This feature was [introduced][ce-4040] in GitLab 8.8. - -With the Docker Container Registry integrated into GitLab, every project can -have its own space to store its Docker images. - -You can read more about Docker Registry at https://docs.docker.com/registry/introduction/. +> [Introduced][ce-4040] in GitLab 8.8. --- -<!-- START doctoc generated TOC please keep comment here to allow auto update --> -<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> -**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* +> **Notes:** +- Container Registry manifest `v1` support was added in GitLab 8.9 to support + Docker versions earlier than 1.10. +- This document is about the admin guide. To learn how to use GitLab Container + Registry [user documentation](../user/project/container_registry.md). -- [Enable the Container Registry](#enable-the-container-registry) -- [Container Registry domain configuration](#container-registry-domain-configuration) - - [Configure Container Registry under an existing GitLab domain](#configure-container-registry-under-an-existing-gitlab-domain) - - [Configure Container Registry under its own domain](#configure-container-registry-under-its-own-domain) -- [Disable Container Registry site-wide](#disable-container-registry-site-wide) -- [Disable Container Registry per project](#disable-container-registry-per-project) -- [Disable Container Registry for new projects site-wide](#disable-container-registry-for-new-projects-site-wide) -- [Container Registry storage path](#container-registry-storage-path) -- [Container Registry storage driver](#container-registry-storage-driver) -- [Storage limitations](#storage-limitations) -- [Changelog](#changelog) +With the Container Registry integrated into GitLab, every project can have its +own space to store its Docker images. -<!-- END doctoc generated TOC please keep comment here to allow auto update --> +You can read more about the Container Registry at +https://docs.docker.com/registry/introduction/. ## Enable the Container Registry **Omnibus GitLab installations** All you have to do is configure the domain name under which the Container -Registry will listen to. Read [#container-registry-domain-configuration](#container-registry-domain-configuration) +Registry will listen to. Read +[#container-registry-domain-configuration](#container-registry-domain-configuration) and pick one of the two options that fits your case. >**Note:** -The container Registry works under HTTPS by default. Using HTTP is possible +The container registry works under HTTPS by default. Using HTTP is possible but not recommended and out of the scope of this document. Read the [insecure Registry documentation][docker-insecure] if you want to implement this. @@ -48,7 +37,7 @@ implement this. If you have installed GitLab from source: -1. You will have to [install Docker Registry][registry-deploy] by yourself. +1. You will have to [install Registry][registry-deploy] by yourself. 1. After the installation is complete, you will have to configure the Registry's settings in `gitlab.yml` in order to enable it. 1. Use the sample NGINX configuration file that is found under @@ -81,11 +70,13 @@ where: | `issuer` | This should be the same value as configured in Registry's `issuer`. Read the [token auth configuration documentation][token-config]. | >**Note:** -GitLab does not ship with a Registry init file. Hence, [restarting GitLab][restart gitlab] -will not restart the Registry should you modify its settings. Read the upstream -documentation on how to achieve that. +A Registry init file is not shipped with GitLab if you install it from source. +Hence, [restarting GitLab][restart gitlab] will not restart the Registry should +you modify its settings. Read the upstream documentation on how to achieve that. -The Docker Registry configuration will need `container_registry` as the service and `https://gitlab.example.com/jwt/auth` as the realm: +At the absolute minimum, make sure your [Registry configuration][registry-auth] +has `container_registry` as the service and `https://gitlab.example.com/jwt/auth` +as the realm: ``` auth: @@ -122,6 +113,10 @@ Registry is exposed to the outside world is `4567`, here is what you need to set in `gitlab.rb` or `gitlab.yml` if you are using Omnibus GitLab or installed GitLab from source respectively. +>**Note:** +Be careful to choose a port different than the one that Registry listens to (`5000` by default), +otherwise you will run into conflicts . + --- **Omnibus GitLab installations** @@ -272,12 +267,6 @@ Registry application itself. 1. Save the file and [restart GitLab][] for the changes to take effect. -## Disable Container Registry per project - -If Registry is enabled in your GitLab instance, but you don't need it for your -project, you can disable it from your project's settings. Read the user guide -on how to achieve that. - ## Disable Container Registry for new projects site-wide If the Container Registry is enabled, then it will be available on all new @@ -403,7 +392,8 @@ To configure the storage driver in Omnibus: 's3' => { 'accesskey' => 's3-access-key', 'secretkey' => 's3-secret-key-for-access-key', - 'bucket' => 'your-s3-bucket' + 'bucket' => 'your-s3-bucket', + 'region' => 'your-s3-region' } } ``` @@ -425,12 +415,53 @@ storage: accesskey: 'AKIAKIAKI' secretkey: 'secret123' bucket: 'gitlab-registry-bucket-AKIAKIAKI' + region: 'your-s3-region' cache: blobdescriptor: inmemory delete: enabled: true ``` +## Change the registry's internal port + +> **Note:** +This is not to be confused with the port that GitLab itself uses to expose +the Registry to the world. + +The Registry server listens on localhost at port `5000` by default, +which is the address for which the Registry server should accept connections. +In the examples below we set the Registry's port to `5001`. + +**Omnibus GitLab** + +1. Open `/etc/gitlab/gitlab.rb` and set `registry['registry_http_addr']`: + + ```ruby + registry['registry_http_addr'] = "localhost:5001" + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +--- + +**Installations from source** + +1. Open the configuration file of your Registry server and edit the + [`http:addr`][registry-http-config] value: + + ``` + http + addr: localhost:5001 + ``` + +1. Save the file and restart the Registry server. + +## Disable Container Registry per project + +If Registry is enabled in your GitLab instance, but you don't need it for your +project, you can disable it from your project's settings. Read the user guide +on how to achieve that. + ## Storage limitations Currently, there is no storage limitation, which means a user can upload an @@ -450,6 +481,8 @@ configurable in future releases. [docker-insecure]: https://docs.docker.com/registry/insecure/ [registry-deploy]: https://docs.docker.com/registry/deploying/ [storage-config]: https://docs.docker.com/registry/configuration/#storage +[registry-http-config]: https://docs.docker.com/registry/configuration/#http +[registry-auth]: https://docs.docker.com/registry/configuration/#auth [token-config]: https://docs.docker.com/registry/configuration/#token [8-8-docs]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-8-stable/doc/administration/container_registry.md [registry-ssl]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/support/nginx/registry-ssl diff --git a/doc/administration/custom_hooks.md b/doc/administration/custom_hooks.md index e3306c22d3f..0387d730489 100644 --- a/doc/administration/custom_hooks.md +++ b/doc/administration/custom_hooks.md @@ -44,8 +44,7 @@ as appropriate. ## Custom error messages ->**Note:** -This feature was [introduced][5073] in GitLab 8.10. +> [Introduced][5073] in GitLab 8.10. If the commit is declined or an error occurs during the Git hook check, the STDERR or STDOUT message of the hook will be present in GitLab's UI. diff --git a/doc/administration/environment_variables.md b/doc/administration/environment_variables.md index 7f53915a4d7..b4a953d1ccc 100644 --- a/doc/administration/environment_variables.md +++ b/doc/administration/environment_variables.md @@ -13,15 +13,17 @@ override certain values. Variable | Type | Description -------- | ---- | ----------- -`GITLAB_ROOT_PASSWORD` | string | Sets the password for the `root` user on installation -`GITLAB_HOST` | string | The full URL of the GitLab server (including `http://` or `https://`) -`RAILS_ENV` | string | The Rails environment; can be one of `production`, `development`, `staging` or `test` -`DATABASE_URL` | string | The database URL; is of the form: `postgresql://localhost/blog_development` -`GITLAB_EMAIL_FROM` | string | The e-mail address used in the "From" field in e-mails sent by GitLab -`GITLAB_EMAIL_DISPLAY_NAME` | string | The name used in the "From" field in e-mails sent by GitLab -`GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab -`GITLAB_UNICORN_MEMORY_MIN` | integer | The minimum memory threshold (in bytes) for the Unicorn worker killer -`GITLAB_UNICORN_MEMORY_MAX` | integer | The maximum memory threshold (in bytes) for the Unicorn worker killer +`GITLAB_ROOT_PASSWORD` | string | Sets the password for the `root` user on installation +`GITLAB_HOST` | string | The full URL of the GitLab server (including `http://` or `https://`) +`RAILS_ENV` | string | The Rails environment; can be one of `production`, `development`, `staging` or `test` +`DATABASE_URL` | string | The database URL; is of the form: `postgresql://localhost/blog_development` +`GITLAB_EMAIL_FROM` | string | The e-mail address used in the "From" field in e-mails sent by GitLab +`GITLAB_EMAIL_DISPLAY_NAME` | string | The name used in the "From" field in e-mails sent by GitLab +`GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab +`GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab +`GITLAB_EMAIL_SUBJECT_SUFFIX` | string | The e-mail subject suffix used in e-mails sent by GitLab +`GITLAB_UNICORN_MEMORY_MIN` | integer | The minimum memory threshold (in bytes) for the Unicorn worker killer +`GITLAB_UNICORN_MEMORY_MAX` | integer | The maximum memory threshold (in bytes) for the Unicorn worker killer ## Complete database variables diff --git a/doc/administration/high_availability/gitlab.md b/doc/administration/high_availability/gitlab.md index 8a881ce8863..137fed35a73 100644 --- a/doc/administration/high_availability/gitlab.md +++ b/doc/administration/high_availability/gitlab.md @@ -101,9 +101,9 @@ need some additional configuration. ```ruby gitlab_shell['secret_token'] = 'fbfb19c355066a9afb030992231c4a363357f77345edd0f2e772359e5be59b02538e1fa6cae8f93f7d23355341cea2b93600dab6d6c3edcdced558fc6d739860' - gitlab_rails['secret_token'] = 'b719fe119132c7810908bba18315259ed12888d4f5ee5430c42a776d840a396799b0a5ef0a801348c8a357f07aa72bbd58e25a84b8f247a25c72f539c7a6c5fa' - gitlab_ci['secret_key_base'] = '6e657410d57c71b4fc3ed0d694e7842b1895a8b401d812c17fe61caf95b48a6d703cb53c112bc01ebd197a85da81b18e29682040e99b4f26594772a4a2c98c6d' - gitlab_ci['db_key_base'] = 'bf2e47b68d6cafaef1d767e628b619365becf27571e10f196f98dc85e7771042b9203199d39aff91fcb6837c8ed83f2a912b278da50999bb11a2fbc0fba52964' + gitlab_rails['otp_key_base'] = 'b719fe119132c7810908bba18315259ed12888d4f5ee5430c42a776d840a396799b0a5ef0a801348c8a357f07aa72bbd58e25a84b8f247a25c72f539c7a6c5fa' + gitlab_rails['secret_key_base'] = '6e657410d57c71b4fc3ed0d694e7842b1895a8b401d812c17fe61caf95b48a6d703cb53c112bc01ebd197a85da81b18e29682040e99b4f26594772a4a2c98c6d' + gitlab_rails['db_key_base'] = 'bf2e47b68d6cafaef1d767e628b619365becf27571e10f196f98dc85e7771042b9203199d39aff91fcb6837c8ed83f2a912b278da50999bb11a2fbc0fba52964' ``` 1. Run `touch /etc/gitlab/skip-auto-migrations` to prevent database migrations diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index f6153216f33..bc424330656 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -1,7 +1,12 @@ # Configuring Redis for GitLab HA -You can choose to install and manage Redis yourself, or you can use GitLab -Omnibus packages to help. +You can choose to install and manage Redis yourself, or you can use the one +that comes bundled with GitLab Omnibus packages. + +> **Note:** Redis does not require authentication by default. See + [Redis Security](http://redis.io/topics/security) documentation for more + information. We recommend using a combination of a Redis password and tight + firewall rules to secure your Redis service. ## Configure your own Redis server @@ -9,49 +14,293 @@ If you're hosting GitLab on a cloud provider, you can optionally use a managed service for Redis. For example, AWS offers a managed ElastiCache service that runs Redis. -> **Note:** Redis does not require authentication by default. See - [Redis Security](http://redis.io/topics/security) documentation for more - information. We recommend using a combination of a Redis password and tight - firewall rules to secure your Redis service. +## Configure Redis using Omnibus -## Configure using Omnibus +If you don't want to bother setting up your own Redis server, you can use the +one bundled with Omnibus. In this case, you should disable all services except +Redis. 1. Download/install GitLab Omnibus using **steps 1 and 2** from [GitLab downloads](https://about.gitlab.com/downloads). Do not complete other steps on the download page. 1. Create/edit `/etc/gitlab/gitlab.rb` and use the following configuration. Be sure to change the `external_url` to match your eventual GitLab front-end - URL. + URL: ```ruby - external_url 'https://gitlab.example.com' + external_url 'https://gitlab.example.com' - # Disable all components except Redis - redis['enable'] = true - bootstrap['enable'] = false - nginx['enable'] = false - unicorn['enable'] = false - sidekiq['enable'] = false - postgresql['enable'] = false - gitlab_workhorse['enable'] = false - mailroom['enable'] = false + # Disable all services except Redis + redis['enable'] = true + bootstrap['enable'] = false + nginx['enable'] = false + unicorn['enable'] = false + sidekiq['enable'] = false + postgresql['enable'] = false + gitlab_workhorse['enable'] = false + mailroom['enable'] = false - # Redis configuration - redis['port'] = 6379 - redis['bind'] = '0.0.0.0' + # Redis configuration + redis['port'] = 6379 + redis['bind'] = '0.0.0.0' - # If you wish to use Redis authentication (recommended) - redis['password'] = 'Redis Password' + # If you wish to use Redis authentication (recommended) + redis['password'] = 'Redis Password' ``` 1. Run `sudo gitlab-ctl reconfigure` to install and configure PostgreSQL. > **Note**: This `reconfigure` step will result in some errors. That's OK - don't be alarmed. + 1. Run `touch /etc/gitlab/skip-auto-migrations` to prevent database migrations from running on upgrade. Only the primary GitLab application server should handle migrations. +## Experimental Redis Sentinel support + +> [Introduced][ce-1877] in GitLab 8.11. + +Since GitLab 8.11, you can configure a list of Redis Sentinel servers that +will monitor a group of Redis servers to provide you with a standard failover +support. + +There is currently one exception to the Sentinel support: `mail_room`, the +component that processes incoming emails. It doesn't support Sentinel yet, but +we hope to integrate a future release that does support it. + +To get a better understanding on how to correctly setup Sentinel, please read +the [Redis Sentinel documentation](http://redis.io/topics/sentinel) first, as +failing to configure it correctly can lead to data loss. + +The configuration consists of three parts: + +- Redis setup +- Sentinel setup +- GitLab setup + +Read carefully how to configure those components below. + +### Redis setup + +You must have at least 2 Redis servers: 1 Master, 1 or more Slaves. +They should be configured the same way and with similar server specs, as +in a failover situation, any Slave can be elected as the new Master by +the Sentinel servers. + +In a minimal setup, the only required change for the slaves in `redis.conf` +is the addition of a `slaveof` line pointing to the initial master. +You can increase the security by defining a `requirepass` configuration in +the master, and `masterauth` in slaves. + +--- + +**Configuring your own Redis server** + +1. Add to the slaves' `redis.conf`: + + ```conf + # IP and port of the master Redis server + slaveof 10.10.10.10 6379 + ``` + +1. Optionally, set up password authentication for increased security. + Add the following to master's `redis.conf`: + + ```conf + # Optional password authentication for increased security + requirepass "<password>" + ``` + +1. Then add this line to all the slave servers' `redis.conf`: + + ```conf + masterauth "<password>" + ``` + +1. Restart the Redis services for the changes to take effect. + +--- + +**Using Redis via Omnibus** + +1. Edit `/etc/gitlab/gitlab.rb` of a master Redis machine (usualy a single machine): + + ```ruby + ## Redis TCP support (will disable UNIX socket transport) + redis['bind'] = '0.0.0.0' # or specify an IP to bind to a single one + redis['port'] = 6379 + + ## Master redis instance + redis['password'] = '<huge password string here>' + ``` + +1. Edit `/etc/gitlab/gitlab.rb` of a slave Redis machine (should be one or more machines): + + ```ruby + ## Redis TCP support (will disable UNIX socket transport) + redis['bind'] = '0.0.0.0' # or specify an IP to bind to a single one + redis['port'] = 6379 + + ## Slave redis instance + redis['master_ip'] = '10.10.10.10' # IP of master Redis server + redis['master_port'] = 6379 # Port of master Redis server + redis['master_password'] = "<huge password string here>" + ``` + +1. Reconfigure the GitLab for the changes to take effect: `sudo gitlab-ctl reconfigure` + +--- + +Now that the Redis servers are all set up, let's configure the Sentinel +servers. + +### Sentinel setup + +We don't provide yet an automated way to setup and run the Sentinel daemon +from Omnibus installation method. You must follow the instructions below and +run it by yourself. + +The support for Sentinel in Ruby has some [caveats](https://github.com/redis/redis-rb/issues/531). +While you can give any name for the `master-group-name` part of the +configuration, as in this example: + +```conf +sentinel monitor <master-group-name> <ip> <port> <quorum> +``` + +,for it to work in Ruby, you have to use the "hostname" of the master Redis +server, otherwise you will get an error message like: +`Redis::CannotConnectError: No sentinels available.`. Read +[Sentinel troubleshooting](#sentinel-troubleshooting) for more information. + +Here is an example configuration file (`sentinel.conf`) for a Sentinel node: + +```conf +port 26379 +sentinel monitor master-redis.example.com 10.10.10.10 6379 1 +sentinel down-after-milliseconds master-redis.example.com 10000 +sentinel config-epoch master-redis.example.com 0 +sentinel leader-epoch master-redis.example.com 0 +``` + +--- + +The final part is to inform the main GitLab application server of the Redis +master and the new sentinels servers. + +### GitLab setup + +You can enable or disable sentinel support at any time in new or existing +installations. From the GitLab application perspective, all it requires is +the correct credentials for the master Redis and for a few Sentinel nodes. + +It doesn't require a list of all Sentinel nodes, as in case of a failure, +the application will need to query only one of them. + +>**Note:** +The following steps should be performed in the [GitLab application server](gitlab.md). + +**For source based installations** + +1. Edit `/home/git/gitlab/config/resque.yml` following the example in + `/home/git/gitlab/config/resque.yml.example`, and uncomment the sentinels + line, changing to the correct server credentials. +1. Restart GitLab for the changes to take effect. + +**For Omnibus installations** + +1. Edit `/etc/gitlab/gitlab.rb` and add/change the following lines: + + ```ruby + gitlab-rails['redis_host'] = "master-redis.example.com" + gitlab-rails['redis_port'] = 6379 + gitlab-rails['redis_password'] = '<huge password string here>' + gitlab-rails['redis_sentinels'] = [ + {'host' => '10.10.10.1', 'port' => 26379}, + {'host' => '10.10.10.2', 'port' => 26379}, + {'host' => '10.10.10.3', 'port' => 26379} + ] + ``` + +1. [Reconfigure] the GitLab for the changes to take effect. + +### Sentinel troubleshooting + +If you get an error like: `Redis::CannotConnectError: No sentinels available.`, +there may be something wrong with your configuration files or it can be related +to [this issue][gh-531] ([pull request][gh-534] that should make things better). + +It's a bit rigid the way you have to config `resque.yml` and `sentinel.conf`, +otherwise `redis-rb` will not work properly. + +The hostname ('my-primary-redis') of the primary Redis server (`sentinel.conf`) +**must** match the one configured in GitLab (`resque.yml` for source installations +or `gitlab-rails['redis_*']` in Omnibus) and it must be valid ex: + +```conf +# sentinel.conf: +sentinel monitor my-primary-redis 10.10.10.10 6379 1 +sentinel down-after-milliseconds my-primary-redis 10000 +sentinel config-epoch my-primary-redis 0 +sentinel leader-epoch my-primary-redis 0 +``` + +```yaml +# resque.yaml +production: + url: redis://my-primary-redis:6378 + sentinels: + - + host: slave1 + port: 26380 # point to sentinel, not to redis port + - + host: slave2 + port: 26381 # point to sentinel, not to redis port +``` + +When in doubt, please read [Redis Sentinel documentation](http://redis.io/topics/sentinel) + +--- + +To make sure your configuration is correct: + +1. SSH into your GitLab application server +1. Enter the Rails console: + + ``` + # For Omnibus installations + sudo gitlab-rails console + + # For source installations + sudo -u git rails console RAILS_ENV=production + ``` + +1. Run in the console: + + ```ruby + redis = Redis.new(Gitlab::Redis.params) + redis.info + ``` + + Keep this screen open and try to simulate a failover below. + +1. To simulate a failover on master Redis, SSH into the Redis server and run: + + ```bash + # port must match your master redis port + redis-cli -h localhost -p 6379 DEBUG sleep 60 + ``` + +1. Then back in the Rails console from the first step, run: + + ``` + redis.info + ``` + + You should see a different port after a few seconds delay + (the failover/reconnect time). + --- Read more on high-availability configuration: @@ -60,3 +309,9 @@ Read more on high-availability configuration: 1. [Configure NFS](nfs.md) 1. [Configure the GitLab application servers](gitlab.md) 1. [Configure the load balancers](load_balancer.md) + +[ce-1877]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1877 +[restart]: ../restart_gitlab.md#installations-from-source +[reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure +[gh-531]: https://github.com/redis/redis-rb/issues/531 +[gh-534]: https://github.com/redis/redis-rb/issues/534 diff --git a/doc/administration/housekeeping.md b/doc/administration/housekeeping.md index a5fa7d358a2..ad1fa98b63b 100644 --- a/doc/administration/housekeeping.md +++ b/doc/administration/housekeeping.md @@ -1,6 +1,6 @@ # Housekeeping -_**Note:** This feature was [introduced][ce-2371] in GitLab 8.4_ +> [Introduced][ce-2371] in GitLab 8.4. --- @@ -12,7 +12,7 @@ revisions (to reduce disk space and increase performance) and removing unreachable objects which may have been created from prior invocations of `git add`. -You can find this option under your **[Project] > Settings**. +You can find this option under your **[Project] > Edit Project**. --- diff --git a/doc/administration/img/housekeeping_settings.png b/doc/administration/img/housekeeping_settings.png Binary files differindex f72ad9a45d5..6ebc6205635 100644 --- a/doc/administration/img/housekeeping_settings.png +++ b/doc/administration/img/housekeeping_settings.png diff --git a/doc/administration/integration/koding.md b/doc/administration/integration/koding.md new file mode 100644 index 00000000000..a2c358af095 --- /dev/null +++ b/doc/administration/integration/koding.md @@ -0,0 +1,242 @@ +# Koding & GitLab + +> [Introduced][ce-5909] in GitLab 8.11. + +This document will guide you through installing and configuring Koding with +GitLab. + +First of all, to be able to use Koding and GitLab together you will need public +access to your server. This allows you to use single sign-on from GitLab to +Koding and using vms from cloud providers like AWS. Koding has a registry for +VMs, called Kontrol and it runs on the same server as Koding itself, VMs from +cloud providers register themselves to Kontrol via the agent that we put into +provisioned VMs. This agent is called Klient and it provides Koding to access +and manage the target machine. + +Kontrol and Klient are based on another technology called +[Kite](https://github.com/koding/kite), that we have written at Koding. Which is a +microservice framework that allows you to develop microservices easily. + +## Requirements + +### Hardware + +Minimum requirements are; + + - 2 cores CPU + - 3G RAM + - 10G Storage + +If you plan to use AWS to install Koding it is recommended that you use at +least a `c3.xlarge` instance. + +### Software + + - [Git](https://git-scm.com) + - [Docker](https://www.docker.com) + - [docker-compose](https://www.docker.com/products/docker-compose) + +Koding can run on most of the UNIX based operating systems, since it's shipped +as containerized with Docker support, it can work on any operating system that +supports Docker. + +Required services are: + +- **PostgreSQL** - Kontrol and Service DB provider +- **MongoDB** - Main DB provider the application +- **Redis** - In memory DB used by both application and services +- **RabbitMQ** - Message Queue for both application and services + +which are also provided as a Docker container by Koding. + + +## Getting Started with Development Versions + + +### Koding + +You can run `docker-compose` environment for developing koding by +executing commands in the following snippet. + +```bash +git clone https://github.com/koding/koding.git +cd koding +docker-compose up +``` + +This should start koding on `localhost:8090`. + +By default there is no team exists in Koding DB. You'll need to create a team +called `gitlab` which is the default team name for GitLab integration in the +configuration. To make things in order it's recommended to create the `gitlab` +team first thing after setting up Koding. + + +### GitLab + +To install GitLab to your environment for development purposes it's recommended +to use GitLab Development Kit which you can get it from +[here](https://gitlab.com/gitlab-org/gitlab-development-kit). + +After all those steps, gitlab should be running on `localhost:3000` + + +## Integration + +Integration includes following components; + + - Single Sign On with OAuth from GitLab to Koding + - System Hook integration for handling GitLab events on Koding + (`project_created`, `user_joined` etc.) + - Service endpoints for importing/executing stacks from GitLab to Koding + (`Run/Try on IDE (Koding)` buttons on GitLab Projects, Issues, MRs) + +As it's pointed out before, you will need public access to this machine that +you've installed Koding and GitLab on. Better to use a domain but a static IP +is also fine. + +For IP based installation you can use [xip.io](https://xip.io) service which is +free and provides DNS resolution to IP based requests like following; + + - 127.0.0.1.xip.io -> resolves to 127.0.0.1 + - foo.bar.baz.127.0.0.1.xip.io -> resolves to 127.0.0.1 + - and so on... + +As Koding needs subdomains for team names; `foo.127.0.0.1.xip.io` requests for +a running koding instance on `127.0.0.1` server will be handled as `foo` team +requests. + + +### GitLab Side + +You need to enable Koding integration from Settings under Admin Area. To do +that login with an Admin account and do followings; + + - open [http://127.0.0.1:3000/admin/application_settings](http://127.0.0.1:3000/admin/application_settings) + - scroll to bottom of the page until Koding section + - check `Enable Koding` checkbox + - provide GitLab team page for running Koding instance as `Koding URL`* + +* For `Koding URL` you need to provide the gitlab integration enabled team on +your Koding installation. Team called `gitlab` has integration on Koding out +of the box, so if you didn't change anything your team on Koding should be +`gitlab`. + +So, if your Koding is running on `http://1.2.3.4.xip.io:8090` your URL needs +to be `http://gitlab.1.2.3.4.xip.io:8090`. You need to provide the same host +with your Koding installation here. + + +#### Registering Koding for OAuth integration + +We need `Application ID` and `Secret` to enable login to Koding via GitLab +feature and to do that you need to register running Koding as a new application +to your running GitLab application. Follow +[these](http://docs.gitlab.com/ce/integration/oauth_provider.html) steps to +enable this integration. + +Redirect URI should be `http://gitlab.127.0.0.1:8090/-/oauth/gitlab/callback` +which again you need to _replace `127.0.0.1` with your instance public IP._ + +Take a copy of `Application ID` and `Secret` that is generated by the GitLab +application, we will need those on _Koding Part_ of this guide. + + +#### Registering system hooks to Koding (optional) + +Koding can take actions based on the events generated by GitLab application. +This feature is still in progress and only following events are processed by +Koding at the moment; + + - user_create + - user_destroy + +All system events are handled but not implemented on Koding side. + +To enable this feature you need to provide a `URL` and a `Secret Token` to your +GitLab application. Open your admin area on your GitLab app from +[http://127.0.0.1:3000/admin/hooks](http://127.0.0.1:3000/admin/hooks) +and provide `URL` as `http://gitlab.127.0.0.1:8090/-/api/gitlab` which is the +endpoint to handle GitLab events on Koding side. Provide a `Secret Token` and +keep a copy of it, we will need it on _Koding Part_ of this guide. + +_(replace `127.0.0.1` with your instance public IP)_ + + +### Koding Part + +If you followed the steps in GitLab part we should have followings to enable +Koding part integrations; + + - `Application ID` and `Secret` for OAuth integration + - `Secret Token` for system hook integration + - Public address of running GitLab instance + + +#### Start Koding with GitLab URL + +Now we need to configure Koding with all this information to get things ready. +If it's already running please stop koding first. + +##### From command-line + +Replace followings with the ones you got from GitLab part of this guide; + +```bash +cd koding +docker-compose run \ + --service-ports backend \ + /opt/koding/scripts/bootstrap-container build \ + --host=**YOUR_IP**.xip.io \ + --gitlabHost=**GITLAB_IP** \ + --gitlabPort=**GITLAB_PORT** \ + --gitlabToken=**SECRET_TOKEN** \ + --gitlabAppId=**APPLICATION_ID** \ + --gitlabAppSecret=**SECRET** +``` + +##### By updating configuration + +Alternatively you can update `gitlab` section on +`config/credentials.default.coffee` like following; + +``` +gitlab = + host: '**GITLAB_IP**' + port: '**GITLAB_PORT**' + applicationId: '**APPLICATION_ID**' + applicationSecret: '**SECRET**' + team: 'gitlab' + redirectUri: '' + systemHookToken: '**SECRET_TOKEN**' + hooksEnabled: yes +``` + +and start by only providing the `host`; + +```bash +cd koding +docker-compose run \ + --service-ports backend \ + /opt/koding/scripts/bootstrap-container build \ + --host=**YOUR_IP**.xip.io \ +``` + +#### Enable Single Sign On + +Once you restarted your Koding and logged in with your username and password +you need to activate oauth authentication for your user. To do that + + - Navigate to Dashboard on Koding from; + `http://gitlab.**YOUR_IP**.xip.io:8090/Home/my-account` + - Scroll down to Integrations section + - Click on toggle to turn On integration in GitLab integration section + +This will redirect you to your GitLab instance and will ask your permission ( +if you are not logged in to GitLab at this point you will be redirected after +login) once you accept you will be redirected to your Koding instance. + +From now on you can login by using `SIGN IN WITH GITLAB` button on your Login +screen in your Koding instance. + +[ce-5909]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5909 diff --git a/doc/administration/issue_closing_pattern.md b/doc/administration/issue_closing_pattern.md new file mode 100644 index 00000000000..28e1fd4e12e --- /dev/null +++ b/doc/administration/issue_closing_pattern.md @@ -0,0 +1,49 @@ +# Issue closing pattern + +>**Note:** +This is the administration documentation. +There is a separate [user documentation] on issue closing pattern. + +When a commit or merge request resolves one or more issues, it is possible to +automatically have these issues closed when the commit or merge request lands +in the project's default branch. + +## Change the issue closing pattern + +In order to change the pattern you need to have access to the server that GitLab +is installed on. + +The default pattern can be located in [gitlab.yml.example] under the +"Automatic issue closing" section. + +> **Tip:** +You are advised to use http://rubular.com to test the issue closing pattern. +Because Rubular doesn't understand `%{issue_ref}`, you can replace this by +`#\d+` when testing your patterns, which matches only local issue references like `#123`. + +**For Omnibus installations** + +1. Open `/etc/gitlab/gitlab.rb` with your editor. +1. Change the value of `gitlab_rails['issue_closing_pattern']` to a regular + expression of your liking: + + ```ruby + gitlab_rails['issue_closing_pattern'] = "((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)" + ``` +1. [Reconfigure] GitLab for the changes to take effect. + +**For installations from source** + +1. Open `gitlab.yml` with your editor. +1. Change the value of `issue_closing_pattern`: + + ```yaml + issue_closing_pattern: "((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)" + ``` + +1. [Restart] GitLab for the changes to take effect. + +[gitlab.yml.example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example +[reconfigure]: restart_gitlab.md#omnibus-gitlab-reconfigure +[restart]: restart_gitlab.md#installations-from-source +[user documentation]: ../user/project/issues/automatic_issue_closing.md diff --git a/doc/administration/monitoring/performance/gitlab_configuration.md b/doc/administration/monitoring/performance/gitlab_configuration.md new file mode 100644 index 00000000000..771584268d9 --- /dev/null +++ b/doc/administration/monitoring/performance/gitlab_configuration.md @@ -0,0 +1,40 @@ +# GitLab Configuration + +GitLab Performance Monitoring is disabled by default. To enable it and change any of its +settings, navigate to the Admin area in **Settings > Metrics** +(`/admin/application_settings`). + +The minimum required settings you need to set are the InfluxDB host and port. +Make sure _Enable InfluxDB Metrics_ is checked and hit **Save** to save the +changes. + +--- + + + +--- + +Finally, a restart of all GitLab processes is required for the changes to take +effect: + +```bash +# For Omnibus installations +sudo gitlab-ctl restart + +# For installations from source +sudo service gitlab restart +``` + +## Pending Migrations + +When any migrations are pending, the metrics are disabled until the migrations +have been performed. + +--- + +Read more on: + +- [Introduction to GitLab Performance Monitoring](introduction.md) +- [InfluxDB Configuration](influxdb_configuration.md) +- [InfluxDB Schema](influxdb_schema.md) +- [Grafana Install/Configuration](grafana_configuration.md) diff --git a/doc/administration/monitoring/performance/grafana_configuration.md b/doc/administration/monitoring/performance/grafana_configuration.md new file mode 100644 index 00000000000..7947b0fedc4 --- /dev/null +++ b/doc/administration/monitoring/performance/grafana_configuration.md @@ -0,0 +1,111 @@ +# Grafana Configuration + +[Grafana](http://grafana.org/) is a tool that allows you to visualize time +series metrics through graphs and dashboards. It supports several backend +data stores, including InfluxDB. GitLab writes performance data to InfluxDB +and Grafana will allow you to query InfluxDB to display useful graphs. + +For the easiest installation and configuration, install Grafana on the same +server as InfluxDB. For larger installations, you may want to split out these +services. + +## Installation + +Grafana supplies package repositories (Yum/Apt) for easy installation. +See [Grafana installation documentation](http://docs.grafana.org/installation/) +for detailed steps. + +> **Note**: Before starting Grafana for the first time, set the admin user +and password in `/etc/grafana/grafana.ini`. Otherwise, the default password +will be `admin`. + +## Configuration + +Login as the admin user. Expand the menu by clicking the Grafana logo in the +top left corner. Choose 'Data Sources' from the menu. Then, click 'Add new' +in the top bar. + + + +Fill in the configuration details for the InfluxDB data source. Save and +Test Connection to ensure the configuration is correct. + +- **Name**: InfluxDB +- **Default**: Checked +- **Type**: InfluxDB 0.9.x (Even if you're using InfluxDB 0.10.x) +- **Url**: https://localhost:8086 (Or the remote URL if you've installed InfluxDB +on a separate server) +- **Access**: proxy +- **Database**: gitlab +- **User**: admin (Or the username configured when setting up InfluxDB) +- **Password**: The password configured when you set up InfluxDB + + + +## Apply retention policies and create continuous queries + +If you intend to import the GitLab provided Grafana dashboards, you will need to +set up the right retention policies and continuous queries. The easiest way of +doing this is by using the [influxdb-management](https://gitlab.com/gitlab-org/influxdb-management) +repository. + +To use this repository you must first clone it: + +``` +git clone https://gitlab.com/gitlab-org/influxdb-management.git +cd influxdb-management +``` + +Next you must install the required dependencies: + +``` +gem install bundler +bundle install +``` + +Now you must configure the repository by first copying `.env.example` to `.env` +and then editing the `.env` file to contain the correct InfluxDB settings. Once +configured you can simply run `bundle exec rake` and the InfluxDB database will +be configured for you. + +For more information see the [influxdb-management README](https://gitlab.com/gitlab-org/influxdb-management/blob/master/README.md). + +## Import Dashboards + +You can now import a set of default dashboards that will give you a good +start on displaying useful information. GitLab has published a set of default +[Grafana dashboards][grafana-dashboards] to get you started. Clone the +repository or download a zip/tarball, then follow these steps to import each +JSON file. + +Open the dashboard dropdown menu and click 'Import' + + + +Click 'Choose file' and browse to the location where you downloaded or cloned +the dashboard repository. Pick one of the JSON files to import. + + + +Once the dashboard is imported, be sure to click save icon in the top bar. If +you do not save the dashboard after importing it will be removed when you +navigate away. + + + +Repeat this process for each dashboard you wish to import. + +Alternatively you can automatically import all the dashboards into your Grafana +instance. See the README of the [Grafana dashboards][grafana-dashboards] +repository for more information on this process. + +[grafana-dashboards]: https://gitlab.com/gitlab-org/grafana-dashboards + +--- + +Read more on: + +- [Introduction to GitLab Performance Monitoring](introduction.md) +- [GitLab Configuration](gitlab_configuration.md) +- [InfluxDB Installation/Configuration](influxdb_configuration.md) +- [InfluxDB Schema](influxdb_schema.md) diff --git a/doc/administration/monitoring/performance/img/grafana_dashboard_dropdown.png b/doc/administration/monitoring/performance/img/grafana_dashboard_dropdown.png Binary files differnew file mode 100644 index 00000000000..7e34fad71ce --- /dev/null +++ b/doc/administration/monitoring/performance/img/grafana_dashboard_dropdown.png diff --git a/doc/administration/monitoring/performance/img/grafana_dashboard_import.png b/doc/administration/monitoring/performance/img/grafana_dashboard_import.png Binary files differnew file mode 100644 index 00000000000..f97624365c7 --- /dev/null +++ b/doc/administration/monitoring/performance/img/grafana_dashboard_import.png diff --git a/doc/administration/monitoring/performance/img/grafana_data_source_configuration.png b/doc/administration/monitoring/performance/img/grafana_data_source_configuration.png Binary files differnew file mode 100644 index 00000000000..7d50e4c88c2 --- /dev/null +++ b/doc/administration/monitoring/performance/img/grafana_data_source_configuration.png diff --git a/doc/administration/monitoring/performance/img/grafana_data_source_empty.png b/doc/administration/monitoring/performance/img/grafana_data_source_empty.png Binary files differnew file mode 100644 index 00000000000..aa39a53acae --- /dev/null +++ b/doc/administration/monitoring/performance/img/grafana_data_source_empty.png diff --git a/doc/administration/monitoring/performance/img/grafana_save_icon.png b/doc/administration/monitoring/performance/img/grafana_save_icon.png Binary files differnew file mode 100644 index 00000000000..c740e33cd1c --- /dev/null +++ b/doc/administration/monitoring/performance/img/grafana_save_icon.png diff --git a/doc/administration/monitoring/performance/img/metrics_gitlab_configuration_settings.png b/doc/administration/monitoring/performance/img/metrics_gitlab_configuration_settings.png Binary files differnew file mode 100644 index 00000000000..db396423e30 --- /dev/null +++ b/doc/administration/monitoring/performance/img/metrics_gitlab_configuration_settings.png diff --git a/doc/administration/monitoring/performance/influxdb_configuration.md b/doc/administration/monitoring/performance/influxdb_configuration.md new file mode 100644 index 00000000000..c30cd2950d8 --- /dev/null +++ b/doc/administration/monitoring/performance/influxdb_configuration.md @@ -0,0 +1,193 @@ +# InfluxDB Configuration + +The default settings provided by [InfluxDB] are not sufficient for a high traffic +GitLab environment. The settings discussed in this document are based on the +settings GitLab uses for GitLab.com, depending on your own needs you may need to +further adjust them. + +If you are intending to run InfluxDB on the same server as GitLab, make sure +you have plenty of RAM since InfluxDB can use quite a bit depending on traffic. + +Unless you are going with a budget setup, it's advised to run it separately. + +## Requirements + +- InfluxDB 0.9.5 or newer +- A fairly modern version of Linux +- At least 4GB of RAM +- At least 10GB of storage for InfluxDB data + +Note that the RAM and storage requirements can differ greatly depending on the +amount of data received/stored. To limit the amount of stored data users can +look into [InfluxDB Retention Policies][influxdb-retention]. + +## Installation + +Installing InfluxDB is out of the scope of this document. Please refer to the +[InfluxDB documentation]. + +## InfluxDB Server Settings + +Since InfluxDB has many settings that users may wish to customize themselves +(e.g. what port to run InfluxDB on), we'll only cover the essentials. + +The configuration file in question is usually located at +`/etc/influxdb/influxdb.conf`. Whenever you make a change in this file, +InfluxDB needs to be restarted. + +### Storage Engine + +InfluxDB comes with different storage engines and as of InfluxDB 0.9.5 a new +storage engine is available, called [TSM Tree]. All users **must** use the new +`tsm1` storage engine as this [will be the default engine][tsm1-commit] in +upcoming InfluxDB releases. + +Make sure you have the following in your configuration file: + +``` +[data] + dir = "/var/lib/influxdb/data" + engine = "tsm1" +``` + +### Admin Panel + +Production environments should have the InfluxDB admin panel **disabled**. This +feature can be disabled by adding the following to your InfluxDB configuration +file: + +``` +[admin] + enabled = false +``` + +### HTTP + +HTTP is required when using the [InfluxDB CLI] or other tools such as Grafana, +thus it should be enabled. When enabling make sure to _also_ enable +authentication: + +``` +[http] + enabled = true + auth-enabled = true +``` + +_**Note:** Before you enable authentication, you might want to [create an +admin user](#create-a-new-admin-user)._ + +### UDP + +GitLab writes data to InfluxDB via UDP and thus this must be enabled. Enabling +UDP can be done using the following settings: + +``` +[[udp]] + enabled = true + bind-address = ":8089" + database = "gitlab" + batch-size = 1000 + batch-pending = 5 + batch-timeout = "1s" + read-buffer = 209715200 +``` + +This does the following: + +1. Enable UDP and bind it to port 8089 for all addresses. +2. Store any data received in the "gitlab" database. +3. Define a batch of points to be 1000 points in size and allow a maximum of + 5 batches _or_ flush them automatically after 1 second. +4. Define a UDP read buffer size of 200 MB. + +One of the most important settings here is the UDP read buffer size as if this +value is set too low, packets will be dropped. You must also make sure the OS +buffer size is set to the same value, the default value is almost never enough. + +To set the OS buffer size to 200 MB, on Linux you can run the following command: + +```bash +sysctl -w net.core.rmem_max=209715200 +``` + +To make this permanent, add the following to `/etc/sysctl.conf` and restart the +server: + +```bash +net.core.rmem_max=209715200 +``` + +It is **very important** to make sure the buffer sizes are large enough to +handle all data sent to InfluxDB as otherwise you _will_ lose data. The above +buffer sizes are based on the traffic for GitLab.com. Depending on the amount of +traffic, users may be able to use a smaller buffer size, but we highly recommend +using _at least_ 100 MB. + +When enabling UDP, users should take care to not expose the port to the public, +as doing so will allow anybody to write data into your InfluxDB database (as +[InfluxDB's UDP protocol][udp] doesn't support authentication). We recommend either +whitelisting the allowed IP addresses/ranges, or setting up a VLAN and only +allowing traffic from members of said VLAN. + +## Create a new admin user + +If you want to [enable authentication](#http), you might want to [create an +admin user][influx-admin]: + +``` +influx -execute "CREATE USER jeff WITH PASSWORD '1234' WITH ALL PRIVILEGES" +``` + +## Create the `gitlab` database + +Once you get InfluxDB up and running, you need to create a database for GitLab. +Make sure you have changed the [storage engine](#storage-engine) to `tsm1` +before creating a database. + +_**Note:** If you [created an admin user](#create-a-new-admin-user) and enabled +[HTTP authentication](#http), remember to append the username (`-username <username>`) +and password (`-password <password>`) you set earlier to the commands below._ + +Run the following command to create a database named `gitlab`: + +```bash +influx -execute 'CREATE DATABASE gitlab' +``` + +The name **must** be `gitlab`, do not use any other name. + +Next, make sure that the database was successfully created: + +```bash +influx -execute 'SHOW DATABASES' +``` + +The output should be similar to: + +``` +name: databases +--------------- +name +_internal +gitlab +``` + +That's it! Now your GitLab instance should send data to InfluxDB. + +--- + +Read more on: + +- [Introduction to GitLab Performance Monitoring](introduction.md) +- [GitLab Configuration](gitlab_configuration.md) +- [InfluxDB Schema](influxdb_schema.md) +- [Grafana Install/Configuration](grafana_configuration.md) + +[influxdb-retention]: https://docs.influxdata.com/influxdb/v0.9/query_language/database_management/#retention-policy-management +[influxdb documentation]: https://docs.influxdata.com/influxdb/v0.9/ +[influxdb cli]: https://docs.influxdata.com/influxdb/v0.9/tools/shell/ +[udp]: https://docs.influxdata.com/influxdb/v0.9/write_protocols/udp/ +[influxdb]: https://influxdata.com/time-series-platform/influxdb/ +[tsm tree]: https://influxdata.com/blog/new-storage-engine-time-structured-merge-tree/ +[tsm1-commit]: https://github.com/influxdata/influxdb/commit/15d723dc77651bac83e09e2b1c94be480966cb0d +[influx-admin]: https://docs.influxdata.com/influxdb/v0.9/administration/authentication_and_authorization/#create-a-new-admin-user diff --git a/doc/administration/monitoring/performance/influxdb_schema.md b/doc/administration/monitoring/performance/influxdb_schema.md new file mode 100644 index 00000000000..eff0e29f58d --- /dev/null +++ b/doc/administration/monitoring/performance/influxdb_schema.md @@ -0,0 +1,97 @@ +# InfluxDB Schema + +The following measurements are currently stored in InfluxDB: + +- `PROCESS_file_descriptors` +- `PROCESS_gc_statistics` +- `PROCESS_memory_usage` +- `PROCESS_method_calls` +- `PROCESS_object_counts` +- `PROCESS_transactions` +- `PROCESS_views` +- `events` + +Here, `PROCESS` is replaced with either `rails` or `sidekiq` depending on the +process type. In all series, any form of duration is stored in milliseconds. + +## PROCESS_file_descriptors + +This measurement contains the number of open file descriptors over time. The +value field `value` contains the number of descriptors. + +## PROCESS_gc_statistics + +This measurement contains Ruby garbage collection statistics such as the amount +of minor/major GC runs (relative to the last sampling interval), the time spent +in garbage collection cycles, and all fields/values returned by `GC.stat`. + +## PROCESS_memory_usage + +This measurement contains the process' memory usage (in bytes) over time. The +value field `value` contains the number of bytes. + +## PROCESS_method_calls + +This measurement contains the methods called during a transaction along with +their duration, and a name of the transaction action that invoked the method (if +available). The method call duration is stored in the value field `duration`, +while the method name is stored in the tag `method`. The tag `action` contains +the full name of the transaction action. Both the `method` and `action` fields +are in the following format: + +``` +ClassName#method_name +``` + +For example, a method called by the `show` method in the `UsersController` class +would have `action` set to `UsersController#show`. + +## PROCESS_object_counts + +This measurement is used to store retained Ruby objects (per class) and the +amount of retained objects. The number of objects is stored in the `count` value +field while the class name is stored in the `type` tag. + +## PROCESS_transactions + +This measurement is used to store basic transaction details such as the time it +took to complete a transaction, how much time was spent in SQL queries, etc. The +following value fields are available: + +| Value | Description | +| ----- | ----------- | +| `duration` | The total duration of the transaction | +| `allocated_memory` | The amount of bytes allocated while the transaction was running. This value is only reliable when using single-threaded application servers | +| `method_duration` | The total time spent in method calls | +| `sql_duration` | The total time spent in SQL queries | +| `view_duration` | The total time spent in views | + +## PROCESS_views + +This measurement is used to store view rendering timings for a transaction. The +following value fields are available: + +| Value | Description | +| ----- | ----------- | +| `duration` | The rendering time of the view | +| `view` | The path of the view, relative to the application's root directory | + +The `action` tag contains the action name of the transaction that rendered the +view. + +## events + +This measurement is used to store generic events such as the number of Git +pushes, Emails sent, etc. Each point in this measurement has a single value +field called `count`. The value of this field is simply set to `1`. Each point +also has at least one tag: `event`. This tag's value is set to the event name. +Depending on the event type additional tags may be available as well. + +--- + +Read more on: + +- [Introduction to GitLab Performance Monitoring](introduction.md) +- [GitLab Configuration](gitlab_configuration.md) +- [InfluxDB Configuration](influxdb_configuration.md) +- [Grafana Install/Configuration](grafana_configuration.md) diff --git a/doc/administration/monitoring/performance/introduction.md b/doc/administration/monitoring/performance/introduction.md new file mode 100644 index 00000000000..79904916b7e --- /dev/null +++ b/doc/administration/monitoring/performance/introduction.md @@ -0,0 +1,65 @@ +# GitLab Performance Monitoring + +GitLab comes with its own application performance measuring system as of GitLab +8.4, simply called "GitLab Performance Monitoring". GitLab Performance Monitoring is available in both the +Community and Enterprise editions. + +Apart from this introduction, you are advised to read through the following +documents in order to understand and properly configure GitLab Performance Monitoring: + +- [GitLab Configuration](gitlab_configuration.md) +- [InfluxDB Install/Configuration](influxdb_configuration.md) +- [InfluxDB Schema](influxdb_schema.md) +- [Grafana Install/Configuration](grafana_configuration.md) + +## Introduction to GitLab Performance Monitoring + +GitLab Performance Monitoring makes it possible to measure a wide variety of statistics +including (but not limited to): + +- The time it took to complete a transaction (a web request or Sidekiq job). +- The time spent in running SQL queries and rendering HAML views. +- The time spent executing (instrumented) Ruby methods. +- Ruby object allocations, and retained objects in particular. +- System statistics such as the process' memory usage and open file descriptors. +- Ruby garbage collection statistics. + +Metrics data is written to [InfluxDB][influxdb] over [UDP][influxdb-udp]. Stored +data can be visualized using [Grafana][grafana] or any other application that +supports reading data from InfluxDB. Alternatively data can be queried using the +InfluxDB CLI. + +## Metric Types + +Two types of metrics are collected: + +1. Transaction specific metrics. +1. Sampled metrics, collected at a certain interval in a separate thread. + +### Transaction Metrics + +Transaction metrics are metrics that can be associated with a single +transaction. This includes statistics such as the transaction duration, timings +of any executed SQL queries, time spent rendering HAML views, etc. These metrics +are collected for every Rack request and Sidekiq job processed. + +### Sampled Metrics + +Sampled metrics are metrics that can't be associated with a single transaction. +Examples include garbage collection statistics and retained Ruby objects. These +metrics are collected at a regular interval. This interval is made up out of two +parts: + +1. A user defined interval. +1. A randomly generated offset added on top of the interval, the same offset + can't be used twice in a row. + +The actual interval can be anywhere between a half of the defined interval and a +half above the interval. For example, for a user defined interval of 15 seconds +the actual interval can be anywhere between 7.5 and 22.5. The interval is +re-generated for every sampling run instead of being generated once and re-used +for the duration of the process' lifetime. + +[influxdb]: https://influxdata.com/time-series-platform/influxdb/ +[influxdb-udp]: https://docs.influxdata.com/influxdb/v0.9/write_protocols/udp/ +[grafana]: http://grafana.org/ diff --git a/doc/administration/operations.md b/doc/administration/operations.md new file mode 100644 index 00000000000..4b582d16b64 --- /dev/null +++ b/doc/administration/operations.md @@ -0,0 +1,6 @@ +# GitLab operations + +- [Sidekiq MemoryKiller](operations/sidekiq_memory_killer.md) +- [Cleaning up Redis sessions](operations/cleaning_up_redis_sessions.md) +- [Understanding Unicorn and unicorn-worker-killer](operations/unicorn.md) +- [Moving repositories to a new location](operations/moving_repositories.md) diff --git a/doc/administration/operations/cleaning_up_redis_sessions.md b/doc/administration/operations/cleaning_up_redis_sessions.md new file mode 100644 index 00000000000..93521e976d5 --- /dev/null +++ b/doc/administration/operations/cleaning_up_redis_sessions.md @@ -0,0 +1,52 @@ +# Cleaning up stale Redis sessions + +Since version 6.2, GitLab stores web user sessions as key-value pairs in Redis. +Prior to GitLab 7.3, user sessions did not automatically expire from Redis. If +you have been running a large GitLab server (thousands of users) since before +GitLab 7.3 we recommend cleaning up stale sessions to compact the Redis +database after you upgrade to GitLab 7.3. You can also perform a cleanup while +still running GitLab 7.2 or older, but in that case new stale sessions will +start building up again after you clean up. + +In GitLab versions prior to 7.3.0, the session keys in Redis are 16-byte +hexadecimal values such as '976aa289e2189b17d7ef525a6702ace9'. Starting with +GitLab 7.3.0, the keys are +prefixed with 'session:gitlab:', so they would look like +'session:gitlab:976aa289e2189b17d7ef525a6702ace9'. Below we describe how to +remove the keys in the old format. + +First we define a shell function with the proper Redis connection details. + +``` +rcli() { + # This example works for Omnibus installations of GitLab 7.3 or newer. For an + # installation from source you will have to change the socket path and the + # path to redis-cli. + sudo /opt/gitlab/embedded/bin/redis-cli -s /var/opt/gitlab/redis/redis.socket "$@" +} + +# test the new shell function; the response should be PONG +rcli ping +``` + +Now we do a search to see if there are any session keys in the old format for +us to clean up. + +``` +# returns the number of old-format session keys in Redis +rcli keys '*' | grep '^[a-f0-9]\{32\}$' | wc -l +``` + +If the number is larger than zero, you can proceed to expire the keys from +Redis. If the number is zero there is nothing to clean up. + +``` +# Tell Redis to expire each matched key after 600 seconds. +rcli keys '*' | grep '^[a-f0-9]\{32\}$' | awk '{ print "expire", $0, 600 }' | rcli +# This will print '(integer) 1' for each key that gets expired. +``` + +Over the next 15 minutes (10 minutes expiry time plus 5 minutes Redis +background save interval) your Redis database will be compacted. If you are +still using GitLab 7.2, users who are not clicking around in GitLab during the +10 minute expiry window will be signed out of GitLab. diff --git a/doc/administration/operations/moving_repositories.md b/doc/administration/operations/moving_repositories.md new file mode 100644 index 00000000000..54adb99386a --- /dev/null +++ b/doc/administration/operations/moving_repositories.md @@ -0,0 +1,180 @@ +# Moving repositories managed by GitLab + +Sometimes you need to move all repositories managed by GitLab to +another filesystem or another server. In this document we will look +at some of the ways you can copy all your repositories from +`/var/opt/gitlab/git-data/repositories` to `/mnt/gitlab/repositories`. + +We will look at three scenarios: the target directory is empty, the +target directory contains an outdated copy of the repositories, and +how to deal with thousands of repositories. + +**Each of the approaches we list can/will overwrite data in the +target directory `/mnt/gitlab/repositories`. Do not mix up the +source and the target.** + +## Target directory is empty: use a tar pipe + +If the target directory `/mnt/gitlab/repositories` is empty the +simplest thing to do is to use a tar pipe. This method has low +overhead and tar is almost always already installed on your system. +However, it is not possible to resume an interrupted tar pipe: if +that happens then all data must be copied again. + +``` +# As the git user +tar -C /var/opt/gitlab/git-data/repositories -cf - -- . |\ + tar -C /mnt/gitlab/repositories -xf - +``` + +If you want to see progress, replace `-xf` with `-xvf`. + +### Tar pipe to another server + +You can also use a tar pipe to copy data to another server. If your +'git' user has SSH access to the newserver as 'git@newserver', you +can pipe the data through SSH. + +``` +# As the git user +tar -C /var/opt/gitlab/git-data/repositories -cf - -- . |\ + ssh git@newserver tar -C /mnt/gitlab/repositories -xf - +``` + +If you want to compress the data before it goes over the network +(which will cost you CPU cycles) you can replace `ssh` with `ssh -C`. + +## The target directory contains an outdated copy of the repositories: use rsync + +If the target directory already contains a partial / outdated copy +of the repositories it may be wasteful to copy all the data again +with tar. In this scenario it is better to use rsync. This utility +is either already installed on your system or easily installable +via apt, yum etc. + +``` +# As the 'git' user +rsync -a --delete /var/opt/gitlab/git-data/repositories/. \ + /mnt/gitlab/repositories +``` + +The `/.` in the command above is very important, without it you can +easily get the wrong directory structure in the target directory. +If you want to see progress, replace `-a` with `-av`. + +### Single rsync to another server + +If the 'git' user on your source system has SSH access to the target +server you can send the repositories over the network with rsync. + +``` +# As the 'git' user +rsync -a --delete /var/opt/gitlab/git-data/repositories/. \ + git@newserver:/mnt/gitlab/repositories +``` + +## Thousands of Git repositories: use one rsync per repository + +Every time you start an rsync job it has to inspect all files in +the source directory, all files in the target directory, and then +decide what files to copy or not. If the source or target directory +has many contents this startup phase of rsync can become a burden +for your GitLab server. In cases like this you can make rsync's +life easier by dividing its work in smaller pieces, and sync one +repository at a time. + +In addition to rsync we will use [GNU +Parallel](http://www.gnu.org/software/parallel/). This utility is +not included in GitLab so you need to install it yourself with apt +or yum. Also note that the GitLab scripts we used below were added +in GitLab 8.1. + +** This process does not clean up repositories at the target location that no +longer exist at the source. ** If you start using your GitLab instance with +`/mnt/gitlab/repositories`, you need to run `gitlab-rake gitlab:cleanup:repos` +after switching to the new repository storage directory. + +### Parallel rsync for all repositories known to GitLab + +This will sync repositories with 10 rsync processes at a time. We keep +track of progress so that the transfer can be restarted if necessary. + +First we create a new directory, owned by 'git', to hold transfer +logs. We assume the directory is empty before we start the transfer +procedure, and that we are the only ones writing files in it. + +``` +# Omnibus +sudo mkdir /var/opt/gitlab/transfer-logs +sudo chown git:git /var/opt/gitlab/transfer-logs + +# Source +sudo -u git -H mkdir /home/git/transfer-logs +``` + +We seed the process with a list of the directories we want to copy. + +``` +# Omnibus +sudo -u git sh -c 'gitlab-rake gitlab:list_repos > /var/opt/gitlab/transfer-logs/all-repos-$(date +%s).txt' + +# Source +cd /home/git/gitlab +sudo -u git -H sh -c 'bundle exec rake gitlab:list_repos > /home/git/transfer-logs/all-repos-$(date +%s).txt' +``` + +Now we can start the transfer. The command below is idempotent, and +the number of jobs done by GNU Parallel should converge to zero. If it +does not some repositories listed in all-repos-1234.txt may have been +deleted/renamed before they could be copied. + +``` +# Omnibus +sudo -u git sh -c ' +cat /var/opt/gitlab/transfer-logs/* | sort | uniq -u |\ + /usr/bin/env JOBS=10 \ + /opt/gitlab/embedded/service/gitlab-rails/bin/parallel-rsync-repos \ + /var/opt/gitlab/transfer-logs/success-$(date +%s).log \ + /var/opt/gitlab/git-data/repositories \ + /mnt/gitlab/repositories +' + +# Source +cd /home/git/gitlab +sudo -u git -H sh -c ' +cat /home/git/transfer-logs/* | sort | uniq -u |\ + /usr/bin/env JOBS=10 \ + bin/parallel-rsync-repos \ + /home/git/transfer-logs/success-$(date +%s).log \ + /home/git/repositories \ + /mnt/gitlab/repositories +` +``` + +### Parallel rsync only for repositories with recent activity + +Suppose you have already done one sync that started after 2015-10-1 12:00 UTC. +Then you might only want to sync repositories that were changed via GitLab +_after_ that time. You can use the 'SINCE' variable to tell 'rake +gitlab:list_repos' to only print repositories with recent activity. + +``` +# Omnibus +sudo gitlab-rake gitlab:list_repos SINCE='2015-10-1 12:00 UTC' |\ + sudo -u git \ + /usr/bin/env JOBS=10 \ + /opt/gitlab/embedded/service/gitlab-rails/bin/parallel-rsync-repos \ + success-$(date +%s).log \ + /var/opt/gitlab/git-data/repositories \ + /mnt/gitlab/repositories + +# Source +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:list_repos SINCE='2015-10-1 12:00 UTC' |\ + sudo -u git -H \ + /usr/bin/env JOBS=10 \ + bin/parallel-rsync-repos \ + success-$(date +%s).log \ + /home/git/repositories \ + /mnt/gitlab/repositories +``` diff --git a/doc/administration/operations/sidekiq_memory_killer.md b/doc/administration/operations/sidekiq_memory_killer.md new file mode 100644 index 00000000000..b5e78348989 --- /dev/null +++ b/doc/administration/operations/sidekiq_memory_killer.md @@ -0,0 +1,40 @@ +# Sidekiq MemoryKiller + +The GitLab Rails application code suffers from memory leaks. For web requests +this problem is made manageable using +[unicorn-worker-killer](https://github.com/kzk/unicorn-worker-killer) which +restarts Unicorn worker processes in between requests when needed. The Sidekiq +MemoryKiller applies the same approach to the Sidekiq processes used by GitLab +to process background jobs. + +Unlike unicorn-worker-killer, which is enabled by default for all GitLab +installations since GitLab 6.4, the Sidekiq MemoryKiller is enabled by default +_only_ for Omnibus packages. The reason for this is that the MemoryKiller +relies on Runit to restart Sidekiq after a memory-induced shutdown and GitLab +installations from source do not all use Runit or an equivalent. + +With the default settings, the MemoryKiller will cause a Sidekiq restart no +more often than once every 15 minutes, with the restart causing about one +minute of delay for incoming background jobs. + +## Configuring the MemoryKiller + +The MemoryKiller is controlled using environment variables. + +- `SIDEKIQ_MEMORY_KILLER_MAX_RSS`: if this variable is set, and its value is + greater than 0, then after each Sidekiq job, the MemoryKiller will check the + RSS of the Sidekiq process that executed the job. If the RSS of the Sidekiq + process (expressed in kilobytes) exceeds SIDEKIQ_MEMORY_KILLER_MAX_RSS, a + delayed shutdown is triggered. The default value for Omnibus packages is set + [in the omnibus-gitlab + repository](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-cookbooks/gitlab/attributes/default.rb). +- `SIDEKIQ_MEMORY_KILLER_GRACE_TIME`: defaults 900 seconds (15 minutes). When + a shutdown is triggered, the Sidekiq process will keep working normally for + another 15 minutes. +- `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT`: defaults to 30 seconds. When the grace + time has expired, the MemoryKiller tells Sidekiq to stop accepting new jobs. + Existing jobs get 30 seconds to finish. After that, the MemoryKiller tells + Sidekiq to shut down, and an external supervision mechanism (e.g. Runit) must + restart Sidekiq. +- `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_SIGNAL`: defaults to `SIGKILL`. The name of + the final signal sent to the Sidekiq process when we want it to shut down. diff --git a/doc/administration/operations/unicorn.md b/doc/administration/operations/unicorn.md new file mode 100644 index 00000000000..bad61151bda --- /dev/null +++ b/doc/administration/operations/unicorn.md @@ -0,0 +1,86 @@ +# Understanding Unicorn and unicorn-worker-killer + +## Unicorn + +GitLab uses [Unicorn](http://unicorn.bogomips.org/), a pre-forking Ruby web +server, to handle web requests (web browsers and Git HTTP clients). Unicorn is +a daemon written in Ruby and C that can load and run a Ruby on Rails +application; in our case the Rails application is GitLab Community Edition or +GitLab Enterprise Edition. + +Unicorn has a multi-process architecture to make better use of available CPU +cores (processes can run on different cores) and to have stronger fault +tolerance (most failures stay isolated in only one process and cannot take down +GitLab entirely). On startup, the Unicorn 'master' process loads a clean Ruby +environment with the GitLab application code, and then spawns 'workers' which +inherit this clean initial environment. The 'master' never handles any +requests, that is left to the workers. The operating system network stack +queues incoming requests and distributes them among the workers. + +In a perfect world, the master would spawn its pool of workers once, and then +the workers handle incoming web requests one after another until the end of +time. In reality, worker processes can crash or time out: if the master notices +that a worker takes too long to handle a request it will terminate the worker +process with SIGKILL ('kill -9'). No matter how the worker process ended, the +master process will replace it with a new 'clean' process again. Unicorn is +designed to be able to replace 'crashed' workers without dropping user +requests. + +This is what a Unicorn worker timeout looks like in `unicorn_stderr.log`. The +master process has PID 56227 below. + +``` +[2015-06-05T10:58:08.660325 #56227] ERROR -- : worker=10 PID:53009 timeout (61s > 60s), killing +[2015-06-05T10:58:08.699360 #56227] ERROR -- : reaped #<Process::Status: pid 53009 SIGKILL (signal 9)> worker=10 +[2015-06-05T10:58:08.708141 #62538] INFO -- : worker=10 spawned pid=62538 +[2015-06-05T10:58:08.708824 #62538] INFO -- : worker=10 ready +``` + +### Tunables + +The main tunables for Unicorn are the number of worker processes and the +request timeout after which the Unicorn master terminates a worker process. +See the [omnibus-gitlab Unicorn settings +documentation](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/settings/unicorn.md) +if you want to adjust these settings. + +## unicorn-worker-killer + +GitLab has memory leaks. These memory leaks manifest themselves in long-running +processes, such as Unicorn workers. (The Unicorn master process is not known to +leak memory, probably because it does not handle user requests.) + +To make these memory leaks manageable, GitLab comes with the +[unicorn-worker-killer gem](https://github.com/kzk/unicorn-worker-killer). This +gem [monkey-patches](https://en.wikipedia.org/wiki/Monkey_patch) the Unicorn +workers to do a memory self-check after every 16 requests. If the memory of the +Unicorn worker exceeds a pre-set limit then the worker process exits. The +Unicorn master then automatically replaces the worker process. + +This is a robust way to handle memory leaks: Unicorn is designed to handle +workers that 'crash' so no user requests will be dropped. The +unicorn-worker-killer gem is designed to only terminate a worker process _in +between requests_, so no user requests are affected. + +This is what a Unicorn worker memory restart looks like in unicorn_stderr.log. +You see that worker 4 (PID 125918) is inspecting itself and decides to exit. +The threshold memory value was 254802235 bytes, about 250MB. With GitLab this +threshold is a random value between 200 and 250 MB. The master process (PID +117565) then reaps the worker process and spawns a new 'worker 4' with PID +127549. + +``` +[2015-06-05T12:07:41.828374 #125918] WARN -- : #<Unicorn::HttpServer:0x00000002734770>: worker (pid: 125918) exceeds memory limit (256413696 bytes > 254802235 bytes) +[2015-06-05T12:07:41.828472 #125918] WARN -- : Unicorn::WorkerKiller send SIGQUIT (pid: 125918) alive: 23 sec (trial 1) +[2015-06-05T12:07:42.025916 #117565] INFO -- : reaped #<Process::Status: pid 125918 exit 0> worker=4 +[2015-06-05T12:07:42.034527 #127549] INFO -- : worker=4 spawned pid=127549 +[2015-06-05T12:07:42.035217 #127549] INFO -- : worker=4 ready +``` + +One other thing that stands out in the log snippet above, taken from +GitLab.com, is that 'worker 4' was serving requests for only 23 seconds. This +is a normal value for our current GitLab.com setup and traffic. + +The high frequency of Unicorn memory restarts on some GitLab sites can be a +source of confusion for administrators. Usually they are a [red +herring](https://en.wikipedia.org/wiki/Red_herring). diff --git a/doc/administration/raketasks/project_import_export.md b/doc/administration/raketasks/project_import_export.md index c212059b9d5..39b1883375e 100644 --- a/doc/administration/raketasks/project_import_export.md +++ b/doc/administration/raketasks/project_import_export.md @@ -1,13 +1,14 @@ # Project import/export >**Note:** - - This feature was [introduced][ce-3050] in GitLab 8.9 - - Importing will not be possible if the import instance version is lower - than that of the exporter. - - For existing installations, the project import option has to be enabled in - application settings (`/admin/application_settings`) under 'Import sources'. - - The exports are stored in a temporary [shared directory][tmp] and are deleted - every 24 hours by a specific worker. +> +> - [Introduced][ce-3050] in GitLab 8.9. +> - Importing will not be possible if the import instance version is lower +> than that of the exporter. +> - For existing installations, the project import option has to be enabled in +> application settings (`/admin/application_settings`) under 'Import sources'. +> - The exports are stored in a temporary [shared directory][tmp] and are deleted +> every 24 hours by a specific worker. The GitLab Import/Export version can be checked by using: diff --git a/doc/administration/reply_by_email.md b/doc/administration/reply_by_email.md new file mode 100644 index 00000000000..5a9a1582877 --- /dev/null +++ b/doc/administration/reply_by_email.md @@ -0,0 +1,302 @@ +# Reply by email + +GitLab can be set up to allow users to comment on issues and merge requests by +replying to notification emails. + +## Requirement + +Reply by email requires an IMAP-enabled email account. GitLab allows you to use +three strategies for this feature: +- using email sub-addressing +- using a dedicated email address +- using a catch-all mailbox + +### Email sub-addressing + +**If your provider or server supports email sub-addressing, we recommend using it.** + +[Sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) is +a feature where any email to `user+some_arbitrary_tag@example.com` will end up +in the mailbox for `user@example.com`, and is supported by providers such as +Gmail, Google Apps, Yahoo! Mail, Outlook.com and iCloud, as well as the Postfix +mail server which you can run on-premises. + +### Dedicated email address + +This solution is really simple to set up: you just have to create an email +address dedicated to receive your users' replies to GitLab notifications. + +### Catch-all mailbox + +A [catch-all mailbox](https://en.wikipedia.org/wiki/Catch-all) for a domain will +"catch all" the emails addressed to the domain that do not exist in the mail +server. + +## How it works? + +### 1. GitLab sends a notification email + +When GitLab sends a notification and Reply by email is enabled, the `Reply-To` +header is set to the address defined in your GitLab configuration, with the +`%{key}` placeholder (if present) replaced by a specific "reply key". In +addition, this "reply key" is also added to the `References` header. + +### 2. You reply to the notification email + +When you reply to the notification email, your email client will: + +- send the email to the `Reply-To` address it got from the notification email +- set the `In-Reply-To` header to the value of the `Message-ID` header from the + notification email +- set the `References` header to the value of the `Message-ID` plus the value of + the notification email's `References` header. + +### 3. GitLab receives your reply to the notification email + +When GitLab receives your reply, it will look for the "reply key" in the +following headers, in this order: + +1. the `To` header +1. the `References` header + +If it finds a reply key, it will be able to leave your reply as a comment on +the entity the notification was about (issue, merge request, commit...). + +For more details about the `Message-ID`, `In-Reply-To`, and `References headers`, +please consult [RFC 5322](https://tools.ietf.org/html/rfc5322#section-3.6.4). + +## Set it up + +If you want to use Gmail / Google Apps with Reply by email, make sure you have +[IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018) +and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255). + +To set up a basic Postfix mail server with IMAP access on Ubuntu, follow +[these instructions](./postfix.md). + +### Omnibus package installations + +1. Find the `incoming_email` section in `/etc/gitlab/gitlab.rb`, enable the + feature and fill in the details for your specific IMAP server and email account: + + ```ruby + # Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com + gitlab_rails['incoming_email_enabled'] = true + + # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. + # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). + gitlab_rails['incoming_email_address'] = "incoming+%{key}@gitlab.example.com" + + # Email account username + # With third party providers, this is usually the full email address. + # With self-hosted email servers, this is usually the user part of the email address. + gitlab_rails['incoming_email_email'] = "incoming" + # Email account password + gitlab_rails['incoming_email_password'] = "[REDACTED]" + + # IMAP server host + gitlab_rails['incoming_email_host'] = "gitlab.example.com" + # IMAP server port + gitlab_rails['incoming_email_port'] = 143 + # Whether the IMAP server uses SSL + gitlab_rails['incoming_email_ssl'] = false + # Whether the IMAP server uses StartTLS + gitlab_rails['incoming_email_start_tls'] = false + + # The mailbox where incoming mail will end up. Usually "inbox". + gitlab_rails['incoming_email_mailbox_name'] = "inbox" + ``` + + ```ruby + # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com + gitlab_rails['incoming_email_enabled'] = true + + # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. + # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). + gitlab_rails['incoming_email_address'] = "gitlab-incoming+%{key}@gmail.com" + + # Email account username + # With third party providers, this is usually the full email address. + # With self-hosted email servers, this is usually the user part of the email address. + gitlab_rails['incoming_email_email'] = "gitlab-incoming@gmail.com" + # Email account password + gitlab_rails['incoming_email_password'] = "[REDACTED]" + + # IMAP server host + gitlab_rails['incoming_email_host'] = "imap.gmail.com" + # IMAP server port + gitlab_rails['incoming_email_port'] = 993 + # Whether the IMAP server uses SSL + gitlab_rails['incoming_email_ssl'] = true + # Whether the IMAP server uses StartTLS + gitlab_rails['incoming_email_start_tls'] = false + + # The mailbox where incoming mail will end up. Usually "inbox". + gitlab_rails['incoming_email_mailbox_name'] = "inbox" + ``` + +1. Reconfigure GitLab and restart mailroom for the changes to take effect: + + ```sh + sudo gitlab-ctl reconfigure + sudo gitlab-ctl restart mailroom + ``` + +1. Verify that everything is configured correctly: + + ```sh + sudo gitlab-rake gitlab:incoming_email:check + ``` + +1. Reply by email should now be working. + +### Installations from source + +1. Go to the GitLab installation directory: + + ```sh + cd /home/git/gitlab + ``` + +1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature + and fill in the details for your specific IMAP server and email account: + + ```sh + sudo editor config/gitlab.yml + ``` + + ```yaml + # Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com + incoming_email: + enabled: true + + # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. + # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). + address: "incoming+%{key}@gitlab.example.com" + + # Email account username + # With third party providers, this is usually the full email address. + # With self-hosted email servers, this is usually the user part of the email address. + user: "incoming" + # Email account password + password: "[REDACTED]" + + # IMAP server host + host: "gitlab.example.com" + # IMAP server port + port: 143 + # Whether the IMAP server uses SSL + ssl: false + # Whether the IMAP server uses StartTLS + start_tls: false + + # The mailbox where incoming mail will end up. Usually "inbox". + mailbox: "inbox" + ``` + + ```yaml + # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com + incoming_email: + enabled: true + + # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. + # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). + address: "gitlab-incoming+%{key}@gmail.com" + + # Email account username + # With third party providers, this is usually the full email address. + # With self-hosted email servers, this is usually the user part of the email address. + user: "gitlab-incoming@gmail.com" + # Email account password + password: "[REDACTED]" + + # IMAP server host + host: "imap.gmail.com" + # IMAP server port + port: 993 + # Whether the IMAP server uses SSL + ssl: true + # Whether the IMAP server uses StartTLS + start_tls: false + + # The mailbox where incoming mail will end up. Usually "inbox". + mailbox: "inbox" + ``` + +1. Enable `mail_room` in the init script at `/etc/default/gitlab`: + + ```sh + sudo mkdir -p /etc/default + echo 'mail_room_enabled=true' | sudo tee -a /etc/default/gitlab + ``` + +1. Restart GitLab: + + ```sh + sudo service gitlab restart + ``` + +1. Verify that everything is configured correctly: + + ```sh + sudo -u git -H bundle exec rake gitlab:incoming_email:check RAILS_ENV=production + ``` + +1. Reply by email should now be working. + +### Development + +1. Go to the GitLab installation directory. + +1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature and fill in the details for your specific IMAP server and email account: + + ```yaml + # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com + incoming_email: + enabled: true + + # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. + # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). + address: "gitlab-incoming+%{key}@gmail.com" + + # Email account username + # With third party providers, this is usually the full email address. + # With self-hosted email servers, this is usually the user part of the email address. + user: "gitlab-incoming@gmail.com" + # Email account password + password: "[REDACTED]" + + # IMAP server host + host: "imap.gmail.com" + # IMAP server port + port: 993 + # Whether the IMAP server uses SSL + ssl: true + # Whether the IMAP server uses StartTLS + start_tls: false + + # The mailbox where incoming mail will end up. Usually "inbox". + mailbox: "inbox" + ``` + + As mentioned, the part after `+` is ignored, and this will end up in the mailbox for `gitlab-incoming@gmail.com`. + +1. Uncomment the `mail_room` line in your `Procfile`: + + ```yaml + mail_room: bundle exec mail_room -q -c config/mail_room.yml + ``` + +1. Restart GitLab: + + ```sh + bundle exec foreman start + ``` + +1. Verify that everything is configured correctly: + + ```sh + bundle exec rake gitlab:incoming_email:check RAILS_ENV=development + ``` + +1. Reply by email should now be working. diff --git a/doc/administration/reply_by_email_postfix_setup.md b/doc/administration/reply_by_email_postfix_setup.md new file mode 100644 index 00000000000..22f10489a6c --- /dev/null +++ b/doc/administration/reply_by_email_postfix_setup.md @@ -0,0 +1,324 @@ +# Set up Postfix for Reply by email + +This document will take you through the steps of setting up a basic Postfix mail +server with IMAP authentication on Ubuntu, to be used with [Reply by email]. + +The instructions make the assumption that you will be using the email address `incoming@gitlab.example.com`, that is, username `incoming` on host `gitlab.example.com`. Don't forget to change it to your actual host when executing the example code snippets. + +## Configure your server firewall + +1. Open up port 25 on your server so that people can send email into the server over SMTP. +2. If the mail server is different from the server running GitLab, open up port 143 on your server so that GitLab can read email from the server over IMAP. + +## Install packages + +1. Install the `postfix` package if it is not installed already: + + ```sh + sudo apt-get install postfix + ``` + + When asked about the environment, select 'Internet Site'. When asked to confirm the hostname, make sure it matches `gitlab.example.com`. + +1. Install the `mailutils` package. + + ```sh + sudo apt-get install mailutils + ``` + +## Create user + +1. Create a user for incoming email. + + ```sh + sudo useradd -m -s /bin/bash incoming + ``` + +1. Set a password for this user. + + ```sh + sudo passwd incoming + ``` + + Be sure not to forget this, you'll need it later. + +## Test the out-of-the-box setup + +1. Connect to the local SMTP server: + + ```sh + telnet localhost 25 + ``` + + You should see a prompt like this: + + ```sh + Trying 127.0.0.1... + Connected to localhost. + Escape character is '^]'. + 220 gitlab.example.com ESMTP Postfix (Ubuntu) + ``` + + If you get a `Connection refused` error instead, verify that `postfix` is running: + + ```sh + sudo postfix status + ``` + + If it is not, start it: + + ```sh + sudo postfix start + ``` + +1. Send the new `incoming` user a dummy email to test SMTP, by entering the following into the SMTP prompt: + + ``` + ehlo localhost + mail from: root@localhost + rcpt to: incoming@localhost + data + Subject: Re: Some issue + + Sounds good! + . + quit + ``` + + _**Note:** The `.` is a literal period on its own line._ + + _**Note:** If you receive an error after entering `rcpt to: incoming@localhost` + then your Postfix `my_network` configuration is not correct. The error will + say 'Temporary lookup failure'. See + [Configure Postfix to receive email from the Internet](#configure-postfix-to-receive-email-from-the-internet)._ + +1. Check if the `incoming` user received the email: + + ```sh + su - incoming + mail + ``` + + You should see output like this: + + ``` + "/var/mail/incoming": 1 message 1 unread + >U 1 root@localhost 59/2842 Re: Some issue + ``` + + Quit the mail app: + + ```sh + q + ``` + +1. Log out of the `incoming` account and go back to being `root`: + + ```sh + logout + ``` + +## Configure Postfix to use Maildir-style mailboxes + +Courier, which we will install later to add IMAP authentication, requires mailboxes to have the Maildir format, rather than mbox. + +1. Configure Postfix to use Maildir-style mailboxes: + + ```sh + sudo postconf -e "home_mailbox = Maildir/" + ``` + +1. Restart Postfix: + + ```sh + sudo /etc/init.d/postfix restart + ``` + +1. Test the new setup: + + 1. Follow steps 1 and 2 of _[Test the out-of-the-box setup](#test-the-out-of-the-box-setup)_. + 1. Check if the `incoming` user received the email: + + ```sh + su - incoming + MAIL=/home/incoming/Maildir + mail + ``` + + You should see output like this: + + ``` + "/home/incoming/Maildir": 1 message 1 unread + >U 1 root@localhost 59/2842 Re: Some issue + ``` + + Quit the mail app: + + ```sh + q + ``` + + _**Note:** If `mail` returns an error `Maildir: Is a directory` then your + version of `mail` doesn't support Maildir style mailboxes. Install + `heirloom-mailx` by running `sudo apt-get install heirloom-mailx`. Then, + try the above steps again, substituting `heirloom-mailx` for the `mail` + command._ + +1. Log out of the `incoming` account and go back to being `root`: + + ```sh + logout + ``` + +## Install the Courier IMAP server + +1. Install the `courier-imap` package: + + ```sh + sudo apt-get install courier-imap + ``` + +## Configure Postfix to receive email from the internet + +1. Let Postfix know about the domains that it should consider local: + + ```sh + sudo postconf -e "mydestination = gitlab.example.com, localhost.localdomain, localhost" + ``` + +1. Let Postfix know about the IPs that it should consider part of the LAN: + + We'll assume `192.168.1.0/24` is your local LAN. You can safely skip this step if you don't have other machines in the same local network. + + ```sh + sudo postconf -e "mynetworks = 127.0.0.0/8, 192.168.1.0/24" + ``` + +1. Configure Postfix to receive mail on all interfaces, which includes the internet: + + ```sh + sudo postconf -e "inet_interfaces = all" + ``` + +1. Configure Postfix to use the `+` delimiter for sub-addressing: + + ```sh + sudo postconf -e "recipient_delimiter = +" + ``` + +1. Restart Postfix: + + ```sh + sudo service postfix restart + ``` + +## Test the final setup + +1. Test SMTP under the new setup: + + 1. Connect to the SMTP server: + + ```sh + telnet gitlab.example.com 25 + ``` + + You should see a prompt like this: + + ```sh + Trying 123.123.123.123... + Connected to gitlab.example.com. + Escape character is '^]'. + 220 gitlab.example.com ESMTP Postfix (Ubuntu) + ``` + + If you get a `Connection refused` error instead, make sure your firewall is setup to allow inbound traffic on port 25. + + 1. Send the `incoming` user a dummy email to test SMTP, by entering the following into the SMTP prompt: + + ``` + ehlo gitlab.example.com + mail from: root@gitlab.example.com + rcpt to: incoming@gitlab.example.com + data + Subject: Re: Some issue + + Sounds good! + . + quit + ``` + + (Note: The `.` is a literal period on its own line) + + 1. Check if the `incoming` user received the email: + + ```sh + su - incoming + MAIL=/home/incoming/Maildir + mail + ``` + + You should see output like this: + + ``` + "/home/incoming/Maildir": 1 message 1 unread + >U 1 root@gitlab.example.com 59/2842 Re: Some issue + ``` + + Quit the mail app: + + ```sh + q + ``` + + 1. Log out of the `incoming` account and go back to being `root`: + + ```sh + logout + ``` + +1. Test IMAP under the new setup: + + 1. Connect to the IMAP server: + + ```sh + telnet gitlab.example.com 143 + ``` + + You should see a prompt like this: + + ```sh + Trying 123.123.123.123... + Connected to mail.example.gitlab.com. + Escape character is '^]'. + - OK [CAPABILITY IMAP4rev1 UIDPLUS CHILDREN NAMESPACE THREAD=ORDEREDSUBJECT THREAD=REFERENCES SORT QUOTA IDLE ACL ACL2=UNION] Courier-IMAP ready. Copyright 1998-2011 Double Precision, Inc. See COPYING for distribution information. + ``` + + 1. Sign in as the `incoming` user to test IMAP, by entering the following into the IMAP prompt: + + ``` + a login incoming PASSWORD + ``` + + Replace PASSWORD with the password you set on the `incoming` user earlier. + + You should see output like this: + + ``` + a OK LOGIN Ok. + ``` + + 1. Disconnect from the IMAP server: + + ```sh + a logout + ``` + +## Done! + +If all the tests were successful, Postfix is all set up and ready to receive email! Continue with the [Reply by email](./README.md) guide to configure GitLab. + +--- + +_This document was adapted from https://help.ubuntu.com/community/PostfixBasicSetupHowto, by contributors to the Ubuntu documentation wiki._ + +[reply by email]: reply_by_email.md diff --git a/doc/administration/repository_checks.md b/doc/administration/repository_checks.md index 4172b604cec..bc2b1f20ed3 100644 --- a/doc/administration/repository_checks.md +++ b/doc/administration/repository_checks.md @@ -1,8 +1,7 @@ # Repository checks ->**Note:** -This feature was [introduced][ce-3232] in GitLab 8.7. It is OFF by -default because it still causes too many false alarms. +> [Introduced][ce-3232] in GitLab 8.7. It is OFF by default because it still +causes too many false alarms. Git has a built-in mechanism, [git fsck][git-fsck], to verify the integrity of all data committed to a repository. GitLab administrators diff --git a/doc/administration/restart_gitlab.md b/doc/administration/restart_gitlab.md index 483060395dd..b561c2f82aa 100644 --- a/doc/administration/restart_gitlab.md +++ b/doc/administration/restart_gitlab.md @@ -139,7 +139,7 @@ If you are using other init systems, like systemd, you can check the [omnibus-dl]: https://about.gitlab.com/downloads/ "Download the Omnibus packages" [install]: ../install/installation.md "Documentation to install GitLab from source" -[mailroom]: ../incoming_email/README.md "Used for replying by email in GitLab issues and merge requests" +[mailroom]: reply_by_email.md "Used for replying by email in GitLab issues and merge requests" [chef]: https://www.chef.io/chef/ "Chef official website" [src-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/support/init.d/gitlab "GitLab init service file" [gl-recipes]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/init "GitLab Recipes repository" diff --git a/doc/administration/troubleshooting/debug.md b/doc/administration/troubleshooting/debug.md index d127d7b85e5..d8dce4388e1 100644 --- a/doc/administration/troubleshooting/debug.md +++ b/doc/administration/troubleshooting/debug.md @@ -144,14 +144,14 @@ separate Rails process to debug the issue: 1. Obtain the private token for your user (Profile Settings -> Account). 1. Bring up the GitLab Rails console. For omnibus users, run: - ```` + ``` sudo gitlab-rails console ``` 1. At the Rails console, run: ```ruby - [1] pry(main)> app.get '<URL FROM STEP 1>/private_token?<TOKEN FROM STEP 2>' + [1] pry(main)> app.get '<URL FROM STEP 2>/?private_token=<TOKEN FROM STEP 3>' ``` For example: diff --git a/doc/api/README.md b/doc/api/README.md index d1e6c54c521..3fbe5197a21 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -10,21 +10,31 @@ following locations: - [Award Emoji](award_emoji.md) - [Branches](branches.md) +- [Broadcast Messages](broadcast_messages.md) - [Builds](builds.md) -- [Build triggers](build_triggers.md) +- [Build Triggers](build_triggers.md) - [Build Variables](build_variables.md) - [Commits](commits.md) +- [Deployments](deployments.md) - [Deploy Keys](deploy_keys.md) +- [Gitignores templates](templates/gitignores.md) +- [GitLab CI Config templates](templates/gitlab_ci_ymls.md) - [Groups](groups.md) +- [Group Access Requests](access_requests.md) +- [Group Members](members.md) - [Issues](issues.md) - [Keys](keys.md) - [Labels](labels.md) - [Merge Requests](merge_requests.md) - [Milestones](milestones.md) -- [Open source license templates](licenses.md) +- [Open source license templates](templates/licenses.md) - [Namespaces](namespaces.md) - [Notes](notes.md) (comments) +- [Notification settings](notification_settings.md) +- [Pipelines](pipelines.md) - [Projects](projects.md) including setting Webhooks +- [Project Access Requests](access_requests.md) +- [Project Members](members.md) - [Project Snippets](project_snippets.md) - [Repositories](repositories.md) - [Repository Files](repository_files.md) @@ -35,8 +45,10 @@ following locations: - [Sidekiq metrics](sidekiq_metrics.md) - [System Hooks](system_hooks.md) - [Tags](tags.md) -- [Users](users.md) - [Todos](todos.md) +- [Users](users.md) +- [Validate CI configuration](ci/lint.md) +- [Version](version.md) ### Internal CI API @@ -47,11 +59,12 @@ The following documentation is for the [internal CI API](ci/README.md): ## Authentication -All API requests require authentication via a token. There are three types of tokens -available: private tokens, OAuth 2 tokens, and personal access tokens. +All API requests require authentication via a session cookie or token. There are +three types of tokens available: private tokens, OAuth 2 tokens, and personal +access tokens. -If a token is invalid or omitted, an error message will be returned with -status code `401`: +If authentication information is invalid or omitted, an error message will be +returned with status code `401`: ```json { @@ -74,14 +87,14 @@ You can use an OAuth 2 token to authenticate with the API by passing it either i Example of using the OAuth2 token in the header: ```shell -curl -H "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v3/projects +curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v3/projects ``` Read more about [GitLab as an OAuth2 client](oauth2.md). ### Personal Access Tokens -> **Note:** This feature was [introduced][ce-3749] in GitLab 8.8 +> [Introduced][ce-3749] in GitLab 8.8. You can create as many personal access tokens as you like from your GitLab profile (`/profile/personal_access_tokens`); perhaps one for each application @@ -90,6 +103,13 @@ that needs access to the GitLab API. Once you have your token, pass it to the API using either the `private_token` parameter or the `PRIVATE-TOKEN` header. + +### Session Cookie + +When signing in to GitLab as an ordinary user, a `_gitlab_session` cookie is +set. The API will use this cookie for authentication if it is present, but using +the API to generate a new session cookie is currently not supported. + ## Basic Usage API requests should be prefixed with `api` and the API version. The API version @@ -154,7 +174,7 @@ be returned with status code `403`: ```json { - "message": "403 Forbidden: Must be admin to use sudo" + "message": "403 Forbidden - Must be admin to use sudo" } ``` @@ -204,7 +224,7 @@ resources you can pass the following parameters: In the example below, we list 50 [namespaces](namespaces.md) per page. ```bash -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/namespaces?per_page=50 +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/namespaces?per_page=50 ``` ### Pagination Link header @@ -218,7 +238,7 @@ and we request the second page (`page=2`) of [comments](notes.md) of the issue with ID `8` which belongs to the project with ID `8`: ```bash -curl -I -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/8/issues/8/notes?per_page=3&page=2 +curl --head --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/8/issues/8/notes?per_page=3&page=2 ``` The response will then be: @@ -338,6 +358,19 @@ follows: } ``` +## Unknown route + +When you try to access an API URL that does not exist you will receive 404 Not Found. + +``` +HTTP/1.1 404 Not Found +Content-Type: application/json +{ + "error": "404 Not Found" +} +``` + + ## Clients There are many unofficial GitLab API Clients for most of the popular diff --git a/doc/api/access_requests.md b/doc/api/access_requests.md new file mode 100644 index 00000000000..ea308b54d62 --- /dev/null +++ b/doc/api/access_requests.md @@ -0,0 +1,147 @@ +# Group and project access requests + + >**Note:** This feature was introduced in GitLab 8.11 + + **Valid access levels** + + The access levels are defined in the `Gitlab::Access` module. Currently, these levels are recognized: + +``` +10 => Guest access +20 => Reporter access +30 => Developer access +40 => Master access +50 => Owner access # Only valid for groups +``` + +## List access requests for a group or project + +Gets a list of access requests viewable by the authenticated user. + +Returns `200` if the request succeeds. + +``` +GET /groups/:id/access_requests +GET /projects/:id/access_requests +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests +``` + +Example response: + +```json +[ + { + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "requested_at": "2012-10-22T14:13:35Z" + }, + { + "id": 2, + "username": "john_doe", + "name": "John Doe", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "requested_at": "2012-10-22T14:13:35Z" + } +] +``` + +## Request access to a group or project + +Requests access for the authenticated user to a group or project. + +Returns `201` if the request succeeds. + +``` +POST /groups/:id/access_requests +POST /projects/:id/access_requests +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "requested_at": "2012-10-22T14:13:35Z" +} +``` + +## Approve an access request + +Approves an access request for the given user. + +Returns `201` if the request succeeds. + +``` +PUT /groups/:id/access_requests/:user_id/approve +PUT /projects/:id/access_requests/:user_id/approve +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the access requester | +| `access_level` | integer | no | A valid access level (defaults: `30`, developer access level) | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests/:user_id/approve?access_level=20 +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests/:user_id/approve?access_level=20 +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 20 +} +``` + +## Deny an access request + +Denies an access request for the given user. + +Returns `200` if the request succeeds. + +``` +DELETE /groups/:id/access_requests/:user_id +DELETE /projects/:id/access_requests/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the access requester | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/access_requests/:user_id +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/access_requests/:user_id +``` diff --git a/doc/api/award_emoji.md b/doc/api/award_emoji.md index 796b3680a75..c464e3f3f71 100644 --- a/doc/api/award_emoji.md +++ b/doc/api/award_emoji.md @@ -1,12 +1,13 @@ # Award Emoji - >**Note:** This feature was introduced in GitLab 8.9 +> [Introduced][ce-4575] in GitLab 8.9, Snippet support in 8.12 + An awarded emoji tells a thousand words, and can be awarded on issues, merge -requests and notes/comments. Issues, merge requests and notes are further called +requests, snippets, and notes/comments. Issues, merge requests, snippets, and notes are further called `awardables`. -## Issues and merge requests +## Issues, merge requests, and snippets ### List an awardable's award emoji @@ -15,6 +16,7 @@ Gets a list of all award emoji ``` GET /projects/:id/issues/:issue_id/award_emoji GET /projects/:id/merge_requests/:merge_request_id/award_emoji +GET /projects/:id/snippets/:snippet_id/award_emoji ``` Parameters: @@ -25,7 +27,7 @@ Parameters: | `awardable_id` | integer | yes | The ID of an awardable | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji ``` Example Response: @@ -69,11 +71,12 @@ Example Response: ### Get single award emoji -Gets a single award emoji from an issue or merge request. +Gets a single award emoji from an issue, snippet, or merge request. ``` GET /projects/:id/issues/:issue_id/award_emoji/:award_id GET /projects/:id/merge_requests/:merge_request_id/award_emoji/:award_id +GET /projects/:id/snippets/:snippet_id/award_emoji/:award_id ``` Parameters: @@ -85,7 +88,7 @@ Parameters: | `award_id` | integer | yes | The ID of the award emoji | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji/1 +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji/1 ``` Example Response: @@ -116,6 +119,7 @@ This end point creates an award emoji on the specified resource ``` POST /projects/:id/issues/:issue_id/award_emoji POST /projects/:id/merge_requests/:merge_request_id/award_emoji +POST /projects/:id/snippets/:snippet_id/award_emoji ``` Parameters: @@ -127,7 +131,7 @@ Parameters: | `name` | string | yes | The name of the emoji, without colons | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji?name=blowfish +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji?name=blowfish ``` Example Response: @@ -159,6 +163,7 @@ admins or the author of the award. Status code 200 on success, 401 if unauthoriz ``` DELETE /projects/:id/issues/:issue_id/award_emoji/:award_id DELETE /projects/:id/merge_requests/:merge_request_id/award_emoji/:award_id +DELETE /projects/:id/snippets/:snippet_id/award_emoji/:award_id ``` Parameters: @@ -170,7 +175,7 @@ Parameters: | `award_id` | integer | yes | The ID of a award_emoji | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji/344 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji/344 ``` Example Response: @@ -197,7 +202,7 @@ Example Response: ## Award Emoji on Notes The endpoints documented above are available for Notes as well. Notes -are a sub-resource of Issues and Merge Requests. The examples below +are a sub-resource of Issues, Merge Requests, or Snippets. The examples below describe working with Award Emoji on notes for an Issue, but can be easily adapted for notes on a Merge Request. @@ -217,7 +222,7 @@ Parameters: ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/notes/1/award_emoji +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/notes/1/award_emoji ``` Example Response: @@ -259,7 +264,7 @@ Parameters: | `award_id` | integer | yes | The ID of the award emoji | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/notes/1/award_emoji/2 +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/notes/1/award_emoji/2 ``` Example Response: @@ -299,7 +304,7 @@ Parameters: | `name` | string | yes | The name of the emoji, without colons | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/notes/1/award_emoji?name=rocket +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/notes/1/award_emoji?name=rocket ``` Example Response: @@ -342,7 +347,7 @@ Parameters: | `award_id` | integer | yes | The ID of a award_emoji | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji/345 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/projects/1/issues/80/award_emoji/345 ``` Example Response: @@ -365,3 +370,5 @@ Example Response: "awardable_type": "Note" } ``` + +[ce-4575]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4575 diff --git a/doc/api/boards.md b/doc/api/boards.md new file mode 100644 index 00000000000..28681719f43 --- /dev/null +++ b/doc/api/boards.md @@ -0,0 +1,251 @@ +# Boards + +Every API call to boards must be authenticated. + +If a user is not a member of a project and the project is private, a `GET` +request on that project will result to a `404` status code. + +## Project Board + +Lists Issue Boards in the given project. + +``` +GET /projects/:id/boards +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/boards +``` + +Example response: + +```json +[ + { + "id" : 1, + "lists" : [ + { + "id" : 1, + "label" : { + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 + }, + { + "id" : 2, + "label" : { + "name" : "Ready", + "color" : "#FF0000", + "description" : null + }, + "position" : 2 + }, + { + "id" : 3, + "label" : { + "name" : "Production", + "color" : "#FF5F00", + "description" : null + }, + "position" : 3 + } + ] + } +] +``` + +## List board lists + +Get a list of the board's lists. +Does not include `backlog` and `done` lists + +``` +GET /projects/:id/boards/:board_id/lists +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `board_id` | integer | yes | The ID of a board | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists +``` + +Example response: + +```json +[ + { + "id" : 1, + "label" : { + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 + }, + { + "id" : 2, + "label" : { + "name" : "Ready", + "color" : "#FF0000", + "description" : null + }, + "position" : 2 + }, + { + "id" : 3, + "label" : { + "name" : "Production", + "color" : "#FF5F00", + "description" : null + }, + "position" : 3 + } +] +``` + +## Single board list + +Get a single board list. + +``` +GET /projects/:id/boards/:board_id/lists/:list_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `board_id` | integer | yes | The ID of a board | +| `list_id`| integer | yes | The ID of a board's list | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists/1 +``` + +Example response: + +```json +{ + "id" : 1, + "label" : { + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 +} +``` + +## New board list + +Creates a new Issue Board list. + +If the operation is successful, a status code of `200` and the newly-created +list is returned. If an error occurs, an error number and a message explaining +the reason is returned. + +``` +POST /projects/:id/boards/:board_id/lists +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `board_id` | integer | yes | The ID of a board | +| `label_id` | integer | yes | The ID of a label | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists?label_id=5 +``` + +Example response: + +```json +{ + "id" : 1, + "label" : { + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 +} +``` + +## Edit board list + +Updates an existing Issue Board list. This call is used to change list position. + +If the operation is successful, a code of `200` and the updated board list is +returned. If an error occurs, an error number and a message explaining the +reason is returned. + +``` +PUT /projects/:id/boards/:board_id/lists/:list_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `board_id` | integer | yes | The ID of a board | +| `list_id` | integer | yes | The ID of a board's list | +| `position` | integer | yes | The position of the list | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists/1?position=2 +``` + +Example response: + +```json +{ + "id" : 1, + "label" : { + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 +} +``` + +## Delete a board list + +Only for admins and project owners. Soft deletes the board list in question. +If the operation is successful, a status code `200` is returned. In case you cannot +destroy this board list, or it is not present, code `404` is given. + +``` +DELETE /projects/:id/boards/:board_id/lists/:list_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of a project | +| `board_id` | integer | yes | The ID of a board | +| `list_id` | integer | yes | The ID of a board's list | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists/1 +``` +Example response: + +```json +{ + "id" : 1, + "label" : { + "name" : "Testing", + "color" : "#F0AD4E", + "description" : null + }, + "position" : 1 +} +``` diff --git a/doc/api/branches.md b/doc/api/branches.md index dbe8306c66f..0b5f7778fc7 100644 --- a/doc/api/branches.md +++ b/doc/api/branches.md @@ -13,7 +13,7 @@ GET /projects/:id/repository/branches | `id` | integer | yes | The ID of a project | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches ``` Example response: @@ -57,7 +57,7 @@ GET /projects/:id/repository/branches/:branch | `branch` | string | yes | The name of the branch | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches/master +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches/master ``` Example response: @@ -95,7 +95,7 @@ PUT /projects/:id/repository/branches/:branch/protect ``` ```bash -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches/master/protect?developers_can_push=true&developers_can_merge=true +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches/master/protect?developers_can_push=true&developers_can_merge=true ``` | Attribute | Type | Required | Description | @@ -140,7 +140,7 @@ PUT /projects/:id/repository/branches/:branch/unprotect ``` ```bash -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches/master/unprotect +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/branches/master/unprotect ``` | Attribute | Type | Required | Description | @@ -185,7 +185,7 @@ POST /projects/:id/repository/branches | `ref` | string | yes | The branch name or commit SHA to create branch from | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/branches?branch_name=newbranch&ref=master" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/branches?branch_name=newbranch&ref=master" ``` Example response: @@ -230,7 +230,7 @@ It returns `200` if it succeeds, `404` if the branch to be deleted does not exis or `400` for other reasons. In case of an error, an explaining message is provided. ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/branches/newbranch" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/branches/newbranch" ``` Example response: diff --git a/doc/api/broadcast_messages.md b/doc/api/broadcast_messages.md new file mode 100644 index 00000000000..c3a9207a3ae --- /dev/null +++ b/doc/api/broadcast_messages.md @@ -0,0 +1,158 @@ +# Broadcast Messages + +> **Note:** This feature was introduced in GitLab 8.12. + +The broadcast message API is only accessible to administrators. All requests by +guests will respond with `401 Unauthorized`, and all requests by normal users +will respond with `403 Forbidden`. + +## Get all broadcast messages + +``` +GET /broadcast_messages +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/broadcast_messages +``` + +Example response: + +```json +[ + { + "message":"Example broadcast message", + "starts_at":"2016-08-24T23:21:16.078Z", + "ends_at":"2016-08-26T23:21:16.080Z", + "color":"#E75E40", + "font":"#FFFFFF", + "id":1, + "active": false + } +] +``` + +## Get a specific broadcast message + +``` +GET /broadcast_messages/:id +``` + +| Attribute | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------- | +| `id` | integer | yes | Broadcast message ID | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/broadcast_messages/1 +``` + +Example response: + +```json +{ + "message":"Deploy in progress", + "starts_at":"2016-08-24T23:21:16.078Z", + "ends_at":"2016-08-26T23:21:16.080Z", + "color":"#cecece", + "font":"#FFFFFF", + "id":1, + "active":false +} +``` + +## Create a broadcast message + +Responds with `400 Bad request` when the `message` parameter is missing or the +`color` or `font` values are invalid, and `201 Created` when the broadcast +message was successfully created. + +``` +POST /broadcast_messages +``` + +| Attribute | Type | Required | Description | +| ----------- | -------- | -------- | ---------------------------------------------------- | +| `message` | string | yes | Message to display | +| `starts_at` | datetime | no | Starting time (defaults to current time) | +| `ends_at` | datetime | no | Ending time (defaults to one hour from current time) | +| `color` | string | no | Background color hex code | +| `font` | string | no | Foreground color hex code | + +```bash +curl --data "message=Deploy in progress&color=#cecece" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/broadcast_messages +``` + +Example response: + +```json +{ + "message":"Deploy in progress", + "starts_at":"2016-08-26T00:41:35.060Z", + "ends_at":"2016-08-26T01:41:35.060Z", + "color":"#cecece", + "font":"#FFFFFF", + "id":1, + "active": true +} +``` + +## Update a broadcast message + +``` +PUT /broadcast_messages/:id +``` + +| Attribute | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------- | +| `id` | integer | yes | Broadcast message ID | +| `message` | string | no | Message to display | +| `starts_at` | datetime | no | Starting time | +| `ends_at` | datetime | no | Ending time | +| `color` | string | no | Background color hex code | +| `font` | string | no | Foreground color hex code | + +```bash +curl --request PUT --data "message=Update message&color=#000" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/broadcast_messages/1 +``` + +Example response: + +```json +{ + "message":"Update message", + "starts_at":"2016-08-26T00:41:35.060Z", + "ends_at":"2016-08-26T01:41:35.060Z", + "color":"#000", + "font":"#FFFFFF", + "id":1, + "active": true +} +``` + +## Delete a broadcast message + +``` +DELETE /broadcast_messages/:id +``` + +| Attribute | Type | Required | Description | +| ----------- | -------- | -------- | ------------------------- | +| `id` | integer | yes | Broadcast message ID | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/broadcast_messages/1 +``` + +Example response: + +```json +{ + "message":"Update message", + "starts_at":"2016-08-26T00:41:35.060Z", + "ends_at":"2016-08-26T01:41:35.060Z", + "color":"#000", + "font":"#FFFFFF", + "id":1, + "active": true +} +``` diff --git a/doc/api/build_triggers.md b/doc/api/build_triggers.md index 0881a7d7a90..1b7a1840138 100644 --- a/doc/api/build_triggers.md +++ b/doc/api/build_triggers.md @@ -15,7 +15,7 @@ GET /projects/:id/triggers | `id` | integer | yes | The ID of a project | ``` -curl -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers" +curl --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers" ``` ```json @@ -51,7 +51,7 @@ GET /projects/:id/triggers/:token | `token` | string | yes | The `token` of a trigger | ``` -curl -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers/7b9148c158980bbd9bcea92c17522d" +curl --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers/7b9148c158980bbd9bcea92c17522d" ``` ```json @@ -77,7 +77,7 @@ POST /projects/:id/triggers | `id` | integer | yes | The ID of a project | ``` -curl -X POST -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers" +curl --request POST --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers" ``` ```json @@ -104,7 +104,7 @@ DELETE /projects/:id/triggers/:token | `token` | string | yes | The `token` of a trigger | ``` -curl -X DELETE -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers/7b9148c158980bbd9bcea92c17522d" +curl --request DELETE --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/triggers/7b9148c158980bbd9bcea92c17522d" ``` ```json diff --git a/doc/api/build_variables.md b/doc/api/build_variables.md index b96f1bdac8a..a21751a49ea 100644 --- a/doc/api/build_variables.md +++ b/doc/api/build_variables.md @@ -13,7 +13,7 @@ GET /projects/:id/variables | `id` | integer | yes | The ID of a project | ``` -curl -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables" +curl --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables" ``` ```json @@ -43,7 +43,7 @@ GET /projects/:id/variables/:key | `key` | string | yes | The `key` of a variable | ``` -curl -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables/TEST_VARIABLE_1" +curl --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables/TEST_VARIABLE_1" ``` ```json @@ -68,7 +68,7 @@ POST /projects/:id/variables | `value` | string | yes | The `value` of a variable | ``` -curl -X POST -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables" -F "key=NEW_VARIABLE" -F "value=new value" +curl --request POST --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables" --form "key=NEW_VARIABLE" --form "value=new value" ``` ```json @@ -93,7 +93,7 @@ PUT /projects/:id/variables/:key | `value` | string | yes | The `value` of a variable | ``` -curl -X PUT -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables/NEW_VARIABLE" -F "value=updated value" +curl --request PUT --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables/NEW_VARIABLE" --form "value=updated value" ``` ```json @@ -117,7 +117,7 @@ DELETE /projects/:id/variables/:key | `key` | string | yes | The `key` of a variable | ``` -curl -X DELETE -H "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables/VARIABLE_1" +curl --request DELETE --header "PRIVATE_TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/variables/VARIABLE_1" ``` ```json diff --git a/doc/api/builds.md b/doc/api/builds.md index 24d90e22a9b..e8a9e4743d3 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -14,7 +14,7 @@ GET /projects/:id/builds | `scope` | string **or** array of strings | no | The scope of builds to show, one or array of: `pending`, `running`, `failed`, `success`, `canceled`; showing all builds if none provided | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds" ``` Example of response @@ -40,6 +40,12 @@ Example of response "finished_at": "2015-12-24T17:54:27.895Z", "id": 7, "name": "teaspoon", + "pipeline": { + "id": 6, + "ref": "master", + "sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "status": "pending" + } "ref": "master", "runner": null, "stage": "test", @@ -78,6 +84,12 @@ Example of response "finished_at": "2015-12-24T17:54:24.921Z", "id": 6, "name": "spinach:other", + "pipeline": { + "id": 6, + "ref": "master", + "sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "status": "pending" + } "ref": "master", "runner": null, "stage": "test", @@ -123,7 +135,7 @@ GET /projects/:id/repository/commits/:sha/builds | `scope` | string **or** array of strings | no | The scope of builds to show, one or array of: `pending`, `running`, `failed`, `success`, `canceled`; showing all builds if none provided | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/repository/commits/0ff3ae198f8601a285adcf5c0fff204ee6fba5fd/builds" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/repository/commits/0ff3ae198f8601a285adcf5c0fff204ee6fba5fd/builds" ``` Example of response @@ -146,6 +158,12 @@ Example of response "finished_at": "2016-01-11T10:14:09.526Z", "id": 69, "name": "rubocop", + "pipeline": { + "id": 6, + "ref": "master", + "sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "status": "pending" + } "ref": "master", "runner": null, "stage": "test", @@ -170,6 +188,12 @@ Example of response "finished_at": "2015-12-24T17:54:33.913Z", "id": 9, "name": "brakeman", + "pipeline": { + "id": 6, + "ref": "master", + "sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "status": "pending" + } "ref": "master", "runner": null, "stage": "test", @@ -209,7 +233,7 @@ GET /projects/:id/builds/:build_id | `build_id` | integer | yes | The ID of a build | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8" ``` Example of response @@ -231,6 +255,12 @@ Example of response "finished_at": "2015-12-24T17:54:31.198Z", "id": 8, "name": "rubocop", + "pipeline": { + "id": 6, + "ref": "master", + "sha": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "status": "pending" + } "ref": "master", "runner": null, "stage": "test", @@ -271,7 +301,7 @@ GET /projects/:id/builds/:build_id/artifacts | `build_id` | integer | yes | The ID of a build | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8/artifacts" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8/artifacts" ``` Response: @@ -305,7 +335,7 @@ Parameters Example request: ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/artifacts/master/download?job=test" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/artifacts/master/download?job=test" ``` Example response: @@ -331,7 +361,7 @@ GET /projects/:id/builds/:build_id/trace | build_id | integer | yes | The ID of a build | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8/trace" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8/trace" ``` Response: @@ -355,7 +385,7 @@ POST /projects/:id/builds/:build_id/cancel | `build_id` | integer | yes | The ID of a build | ``` -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/cancel" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/cancel" ``` Example of response @@ -401,7 +431,7 @@ POST /projects/:id/builds/:build_id/retry | `build_id` | integer | yes | The ID of a build | ``` -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/retry" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/retry" ``` Example of response @@ -451,7 +481,7 @@ Parameters Example of request ``` -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/erase" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/erase" ``` Example of response @@ -501,7 +531,7 @@ Parameters Example request: ``` -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/artifacts/keep" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/artifacts/keep" ``` Example response: @@ -532,3 +562,49 @@ Example response: "user": null } ``` + +## Play a build + +Triggers a manual action to start a build. + +``` +POST /projects/:id/builds/:build_id/play +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `build_id` | integer | yes | The ID of a build | + +``` +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/play" +``` + +Example of response + +```json +{ + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "started", + "tag": false, + "user": null +} +``` diff --git a/doc/api/ci/builds.md b/doc/api/ci/builds.md index d779463fd8c..b6d79706a84 100644 --- a/doc/api/ci/builds.md +++ b/doc/api/ci/builds.md @@ -35,9 +35,18 @@ POST /ci/api/v1/builds/register ``` -curl -X POST "https://gitlab.example.com/ci/api/v1/builds/register" -F "token=t0k3n" +curl --request POST "https://gitlab.example.com/ci/api/v1/builds/register" --form "token=t0k3n" ``` +**Responses:** + +| Status | Data |Description | +|--------|------|---------------------------------------------------------------------------| +| `201` | yes | When a build is scheduled for a runner | +| `204` | no | When no builds are scheduled for a runner (for GitLab Runner >= `v1.3.0`) | +| `403` | no | When invalid token is used or no token is sent | +| `404` | no | When no builds are scheduled for a runner (for GitLab Runner < `v1.3.0`) **or** when the runner is set to `paused` in GitLab runner's configuration page | + ### Update details of an existing build ``` @@ -52,7 +61,7 @@ PUT /ci/api/v1/builds/:id | `trace` | string | no | The trace of a build | ``` -curl -X PUT "https://gitlab.example.com/ci/api/v1/builds/1234" -F "token=t0k3n" -F "state=running" -F "trace=Running git clone...\n" +curl --request PUT "https://gitlab.example.com/ci/api/v1/builds/1234" --form "token=t0k3n" --form "state=running" --form "trace=Running git clone...\n" ``` ### Incremental build trace update @@ -87,7 +96,7 @@ Headers: | `Content-Range` | string | yes | Bytes range of trace that is sent | ``` -curl -X PATCH "https://gitlab.example.com/ci/api/v1/builds/1234/trace.txt" -H "BUILD-TOKEN=build_t0k3n" -H "Content-Range=0-21" -d "Running git clone...\n" +curl --request PATCH "https://gitlab.example.com/ci/api/v1/builds/1234/trace.txt" --header "BUILD-TOKEN=build_t0k3n" --header "Content-Range=0-21" --data "Running git clone...\n" ``` @@ -104,7 +113,7 @@ POST /ci/api/v1/builds/:id/artifacts | `file` | mixed | yes | Artifacts file | ``` -curl -X POST "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -F "file=@/path/to/file" +curl --request POST "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" --form "token=build_t0k3n" --form "file=@/path/to/file" ``` ### Download the artifacts file from build @@ -119,7 +128,7 @@ GET /ci/api/v1/builds/:id/artifacts | `token` | string | yes | The build authorization token | ``` -curl "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" +curl "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" --form "token=build_t0k3n" ``` ### Remove the artifacts file from build @@ -134,5 +143,5 @@ DELETE /ci/api/v1/builds/:id/artifacts | `token` | string | yes | The build authorization token | ``` -curl -X DELETE "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" +curl --request DELETE "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" --form "token=build_t0k3n" ``` diff --git a/doc/api/ci/lint.md b/doc/api/ci/lint.md new file mode 100644 index 00000000000..0c96b3ee335 --- /dev/null +++ b/doc/api/ci/lint.md @@ -0,0 +1,49 @@ +# Validate the .gitlab-ci.yml + +> [Introduced][ce-5953] in GitLab 8.12. + +Checks if your .gitlab-ci.yml file is valid. + +``` +POST ci/lint +``` + +| Attribute | Type | Required | Description | +| ---------- | ------- | -------- | -------- | +| `content` | string | yes | the .gitlab-ci.yaml content| + +```bash +curl --header "Content-Type: application/json" https://gitlab.example.com/api/v3/ci/lint --data '{"content": "{ \"image\": \"ruby:2.1\", \"services\": [\"postgres\"], \"before_script\": [\"gem install bundler\", \"bundle install\", \"bundle exec rake db:create\"], \"variables\": {\"DB_NAME\": \"postgres\"}, \"types\": [\"test\", \"deploy\", \"notify\"], \"rspec\": { \"script\": \"rake spec\", \"tags\": [\"ruby\", \"postgres\"], \"only\": [\"branches\"]}}"}' +``` + +Be sure to copy paste the exact contents of `.gitlab-ci.yml` as YAML is very picky about indentation and spaces. + +Example responses: + +* Valid content: + + ```json + { + "status": "valid", + "errors": [] + } + ``` + +* Invalid content: + + ```json + { + "status": "invalid", + "errors": [ + "variables config should be a hash of key value pairs" + ] + } + ``` + +* Without the content attribute: + + ```json + { + "error": "content is missing" + } + ``` diff --git a/doc/api/ci/runners.md b/doc/api/ci/runners.md index 96b3c42f773..16028d1f124 100644 --- a/doc/api/ci/runners.md +++ b/doc/api/ci/runners.md @@ -12,7 +12,9 @@ communication channel. For the consumer API see the This API uses two types of authentication: 1. Unique Runner's token, which is the token assigned to the Runner after it - has been registered. + has been registered. This token can be found on the Runner's edit page (go to + **Project > Runners**, select one of the Runners listed under **Runners activated for + this project**). 2. Using Runners' registration token. This is a token that can be found in project's settings. @@ -35,7 +37,7 @@ POST /ci/api/v1/runners/register Example request: ```sh -curl -X POST "https://gitlab.example.com/ci/api/v1/runners/register" -F "token=t0k3n" +curl --request POST "https://gitlab.example.com/ci/api/v1/runners/register" --form "token=t0k3n" ``` ## Delete a Runner @@ -48,10 +50,10 @@ DELETE /ci/api/v1/runners/delete | Attribute | Type | Required | Description | | --------- | ------- | --------- | ----------- | -| `token` | string | yes | Runner's registration token | +| `token` | string | yes | Unique Runner's token | Example request: ```sh -curl -X DELETE "https://gitlab.example.com/ci/api/v1/runners/delete" -F "token=t0k3n" +curl --request DELETE "https://gitlab.example.com/ci/api/v1/runners/delete" --form "token=t0k3n" ``` diff --git a/doc/api/commits.md b/doc/api/commits.md index 2960c2ae428..3e20beefb8a 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -10,13 +10,13 @@ GET /projects/:id/repository/commits | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `ref_name` | string | no | The name of a repository branch or tag or if not given the default branch | | `since` | string | no | Only commits after or in this date will be returned in ISO 8601 format YYYY-MM-DDTHH:MM:SSZ | | `until` | string | no | Only commits before or in this date will be returned in ISO 8601 format YYYY-MM-DDTHH:MM:SSZ | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits" ``` Example response: @@ -46,6 +46,91 @@ Example response: ] ``` +## Create a commit with multiple files and actions + +> [Introduced][ce-6096] in GitLab 8.13. + +Create a commit by posting a JSON payload + +``` +POST /projects/:id/repository/commits +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME | +| `branch_name` | string | yes | The name of a branch | +| `commit_message` | string | yes | Commit message | +| `actions[]` | array | yes | An array of action hashes to commit as a batch. See the next table for what attributes it can take. | +| `author_email` | string | no | Specify the commit author's email address | +| `author_name` | string | no | Specify the commit author's name | + + +| `actions[]` Attribute | Type | Required | Description | +| --------------------- | ---- | -------- | ----------- | +| `action` | string | yes | The action to perform, `create`, `delete`, `move`, `update` | +| `file_path` | string | yes | Full path to the file. Ex. `lib/class.rb` | +| `previous_path` | string | no | Original full path to the file being moved. Ex. `lib/class1.rb` | +| `content` | string | no | File content, required for all except `delete`. Optional for `move` | +| `encoding` | string | no | `text` or `base64`. `text` is default. | + +```bash +PAYLOAD=$(cat << 'JSON' +{ + "branch_name": "master", + "commit_message": "some commit message", + "actions": [ + { + "action": "create", + "file_path": "foo/bar", + "content": "some content" + }, + { + "action": "delete", + "file_path": "foo/bar2", + }, + { + "action": "move", + "file_path": "foo/bar3", + "previous_path": "foo/bar4", + "content": "some content" + }, + { + "action": "update", + "file_path": "foo/bar5", + "content": "new content" + } + ] +} +JSON +) +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" --data "$PAYLOAD" https://gitlab.example.com/api/v3/projects/1/repository/commits +``` + +Example response: +```json +{ + "id": "ed899a2f4b50b4370feeea94676502b42383c746", + "short_id": "ed899a2f4b5", + "title": "some commit message", + "author_name": "Dmitriy Zaporozhets", + "author_email": "dzaporozhets@sphereconsultinginc.com", + "created_at": "2016-09-20T09:26:24.000-07:00", + "message": "some commit message", + "parent_ids": [ + "ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba" + ], + "committed_date": "2016-09-20T09:26:24.000-07:00", + "authored_date": "2016-09-20T09:26:24.000-07:00", + "stats": { + "additions": 2, + "deletions": 2, + "total": 4 + }, + "status": null +} +``` + ## Get a single commit Get a specific commit identified by the commit hash or name of a branch or tag. @@ -58,11 +143,11 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `sha` | string | yes | The commit hash or name of a repository branch or tag | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits/master +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits/master ``` Example response: @@ -102,11 +187,11 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `sha` | string | yes | The commit hash or name of a repository branch or tag | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits/master/diff" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits/master/diff" ``` Example response: @@ -138,11 +223,11 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `sha` | string | yes | The commit hash or name of a repository branch or tag | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits/master/comments" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/repository/commits/master/comments" ``` Example response: @@ -187,7 +272,7 @@ POST /projects/:id/repository/commits/:sha/comments | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `sha` | string | yes | The commit SHA or name of a repository branch or tag | | `note` | string | yes | The text of the comment | | `path` | string | no | The file path relative to the repository | @@ -195,7 +280,7 @@ POST /projects/:id/repository/commits/:sha/comments | `line_type` | string | no | The line type. Takes `new` or `old` as arguments | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -F "note=Nice picture man\!" -F "path=dudeism.md" -F "line=11" -F "line_type=new" https://gitlab.example.com/api/v3/projects/17/repository/commits/18f3e63d05582537db6d183d9d557be09e1f90c8/comments +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "note=Nice picture man\!" --form "path=dudeism.md" --form "line=11" --form "line_type=new" https://gitlab.example.com/api/v3/projects/17/repository/commits/18f3e63d05582537db6d183d9d557be09e1f90c8/comments ``` Example response: @@ -232,7 +317,7 @@ GET /projects/:id/repository/commits/:sha/statuses | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `sha` | string | yes | The commit SHA | `ref_name`| string | no | The name of a repository branch or tag or, if not given, the default branch | `stage` | string | no | Filter by [build stage](../ci/yaml/README.md#stages), e.g., `test` @@ -240,7 +325,7 @@ GET /projects/:id/repository/commits/:sha/statuses | `all` | boolean | no | Return all statuses, not only the latest ones ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/17/repository/commits/18f3e63d05582537db6d183d9d557be09e1f90c8/statuses +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/17/repository/commits/18f3e63d05582537db6d183d9d557be09e1f90c8/statuses ``` Example response: @@ -306,7 +391,7 @@ POST /projects/:id/statuses/:sha | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project +| `id` | integer/string | yes | The ID of a project or NAMESPACE/PROJECT_NAME owned by the authenticated user | `sha` | string | yes | The commit SHA | `state` | string | yes | The state of the status. Can be one of the following: `pending`, `running`, `success`, `failed`, `canceled` | `ref` | string | no | The `ref` (branch or tag) to which the status refers @@ -315,7 +400,7 @@ POST /projects/:id/statuses/:sha | `description` | string | no | The short description of the status ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/17/statuses/18f3e63d05582537db6d183d9d557be09e1f90c8?state=success" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/17/statuses/18f3e63d05582537db6d183d9d557be09e1f90c8?state=success" ``` Example response: @@ -343,3 +428,5 @@ Example response: "finished_at" : "2016-01-19T09:05:50.365Z" } ``` + +[ce-6096]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6096 "Multi-file commit" diff --git a/doc/api/deploy_key_multiple_projects.md b/doc/api/deploy_key_multiple_projects.md index 9280f0d68b6..73cb4b7ea8c 100644 --- a/doc/api/deploy_key_multiple_projects.md +++ b/doc/api/deploy_key_multiple_projects.md @@ -7,23 +7,23 @@ First, find the ID of the projects you're interested in, by either listing all projects: ``` -curl -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' https://gitlab.example.com/api/v3/projects +curl --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' https://gitlab.example.com/api/v3/projects ``` Or finding the ID of a group and then listing all projects in that group: ``` -curl -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' https://gitlab.example.com/api/v3/groups +curl --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' https://gitlab.example.com/api/v3/groups # For group 1234: -curl -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' https://gitlab.example.com/api/v3/groups/1234 +curl --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' https://gitlab.example.com/api/v3/groups/1234 ``` With those IDs, add the same deploy key to all: ``` for project_id in 321 456 987; do - curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -H "Content-Type: application/json" \ + curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" \ --data '{"title": "my key", "key": "ssh-rsa AAAA..."}' https://gitlab.example.com/api/v3/projects/${project_id}/deploy_keys done ``` diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md index 4e620ccc81a..ca44afbf355 100644 --- a/doc/api/deploy_keys.md +++ b/doc/api/deploy_keys.md @@ -9,7 +9,7 @@ GET /deploy_keys ``` ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/deploy_keys" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/deploy_keys" ``` Example response: @@ -44,7 +44,7 @@ GET /projects/:id/deploy_keys | `id` | integer | yes | The ID of the project | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/deploy_keys" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/deploy_keys" ``` Example response: @@ -82,7 +82,7 @@ Parameters: | `key_id` | integer | yes | The ID of the deploy key | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/deploy_keys/11" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/deploy_keys/11" ``` Example response: @@ -114,7 +114,7 @@ POST /projects/:id/deploy_keys | `key` | string | yes | New deploy key | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -H "Content-Type: application/json" --data '{"title": "My deploy key", "key": "ssh-rsa AAAA..."}' "https://gitlab.example.com/api/v3/projects/5/deploy_keys/" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" --data '{"title": "My deploy key", "key": "ssh-rsa AAAA..."}' "https://gitlab.example.com/api/v3/projects/5/deploy_keys/" ``` Example response: @@ -142,7 +142,7 @@ DELETE /projects/:id/deploy_keys/:key_id | `key_id` | integer | yes | The ID of the deploy key | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/deploy_keys/13" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/deploy_keys/13" ``` Example response: @@ -159,3 +159,51 @@ Example response: "id" : 13 } ``` + +## Enable a deploy key + +Enables a deploy key for a project so this can be used. Returns the enabled key, with a status code 201 when successful. + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/deploy_keys/13/enable +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the project | +| `key_id` | integer | yes | The ID of the deploy key | + +Example response: + +```json +{ + "key" : "ssh-rsa AAAA...", + "id" : 12, + "title" : "My deploy key", + "created_at" : "2015-08-29T12:44:31.550Z" +} +``` + +## Disable a deploy key + +Disable a deploy key for a project. Returns the disabled key. + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/deploy_keys/13/disable +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the project | +| `key_id` | integer | yes | The ID of the deploy key | + +Example response: + +```json +{ + "key" : "ssh-rsa AAAA...", + "id" : 12, + "title" : "My deploy key", + "created_at" : "2015-08-29T12:44:31.550Z" +} +``` diff --git a/doc/api/deployments.md b/doc/api/deployments.md new file mode 100644 index 00000000000..417962de82d --- /dev/null +++ b/doc/api/deployments.md @@ -0,0 +1,218 @@ +# Deployments API + +## List project deployments + +Get a list of deployments in a project. + +``` +GET /projects/:id/deployments +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/deployments" +``` + +Example of response + +```json +[ + { + "created_at": "2016-08-11T07:36:40.222Z", + "deployable": { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2016-08-11T09:36:01.000+02:00", + "id": "99d03678b90d914dbb1b109132516d71a4a03ea8", + "message": "Merge branch 'new-title' into 'master'\r\n\r\nUpdate README\r\n\r\n\r\n\r\nSee merge request !1", + "short_id": "99d03678", + "title": "Merge branch 'new-title' into 'master'\r" + }, + "coverage": null, + "created_at": "2016-08-11T07:36:27.357Z", + "finished_at": "2016-08-11T07:36:39.851Z", + "id": 657, + "name": "deploy", + "ref": "master", + "runner": null, + "stage": "deploy", + "started_at": null, + "status": "success", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2016-08-11T07:09:20.351Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "location": null, + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://localhost:3000/u/root", + "website_url": "" + } + }, + "environment": { + "external_url": "https://about.gitlab.com", + "id": 9, + "name": "production" + }, + "id": 41, + "iid": 1, + "ref": "master", + "sha": "99d03678b90d914dbb1b109132516d71a4a03ea8", + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "id": 1, + "name": "Administrator", + "state": "active", + "username": "root", + "web_url": "http://localhost:3000/u/root" + } + }, + { + "created_at": "2016-08-11T11:32:35.444Z", + "deployable": { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2016-08-11T13:28:26.000+02:00", + "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2", + "short_id": "a91957a8", + "title": "Merge branch 'rename-readme' into 'master'\r" + }, + "coverage": null, + "created_at": "2016-08-11T11:32:24.456Z", + "finished_at": "2016-08-11T11:32:35.145Z", + "id": 664, + "name": "deploy", + "ref": "master", + "runner": null, + "stage": "deploy", + "started_at": null, + "status": "success", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2016-08-11T07:09:20.351Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "location": null, + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://localhost:3000/u/root", + "website_url": "" + } + }, + "environment": { + "external_url": "https://about.gitlab.com", + "id": 9, + "name": "production" + }, + "id": 42, + "iid": 2, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "id": 1, + "name": "Administrator", + "state": "active", + "username": "root", + "web_url": "http://localhost:3000/u/root" + } + } +] +``` + +## Get a specific deployment + +``` +GET /projects/:id/deployments/:deployment_id +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `deployment_id` | integer | yes | The ID of the deployment | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/deployments/1" +``` + +Example of response + +```json +{ + "id": 42, + "iid": 2, + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "created_at": "2016-08-11T11:32:35.444Z", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "environment": { + "id": 9, + "name": "production", + "external_url": "https://about.gitlab.com" + }, + "deployable": { + "id": 664, + "status": "success", + "stage": "deploy", + "name": "deploy", + "ref": "master", + "tag": false, + "coverage": null, + "created_at": "2016-08-11T11:32:24.456Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root", + "created_at": "2016-08-11T07:09:20.351Z", + "is_admin": true, + "bio": null, + "location": null, + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "" + }, + "commit": { + "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "short_id": "a91957a8", + "title": "Merge branch 'rename-readme' into 'master'\r", + "author_name": "Administrator", + "author_email": "admin@example.com", + "created_at": "2016-08-11T13:28:26.000+02:00", + "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2" + }, + "runner": null + } +} +``` diff --git a/doc/api/enviroments.md b/doc/api/enviroments.md index 1e12ded448c..87a5fa67124 100644 --- a/doc/api/enviroments.md +++ b/doc/api/enviroments.md @@ -13,7 +13,7 @@ GET /projects/:id/environments | `id` | integer | yes | The ID of the project | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/environments +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/environments ``` Example response: @@ -45,7 +45,7 @@ POST /projects/:id/environment | `external_url` | string | no | Place to link to for this environment | ```bash -curl --data "name=deploy&external_url=https://deploy.example.gitlab.com" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environments" +curl --data "name=deploy&external_url=https://deploy.example.gitlab.com" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environments" ``` Example response: @@ -76,7 +76,7 @@ PUT /projects/:id/environments/:environments_id | `external_url` | string | no | The new external_url | ```bash -curl -X PUT --data "name=staging&external_url=https://staging.example.gitlab.com" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environment/1" +curl --request PUT --data "name=staging&external_url=https://staging.example.gitlab.com" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environment/1" ``` Example response: @@ -103,7 +103,7 @@ DELETE /projects/:id/environments/:environment_id | `environment_id` | integer | yes | The ID of the environment | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environment/1" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environment/1" ``` Example response: diff --git a/doc/api/groups.md b/doc/api/groups.md index 87480bebfc4..e81d6f9de4b 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -1,514 +1,444 @@ -# Groups
-
-## List groups
-
-Get a list of groups. (As user: my groups, as admin: all groups)
-
-```
-GET /groups
-```
-
-```json
-[
- {
- "id": 1,
- "name": "Foobar Group",
- "path": "foo-bar",
- "description": "An interesting group"
- }
-]
-```
-
-You can search for groups by name or path, see below.
-
-
-## List a group's projects
-
-Get a list of projects in this group.
-
-```
-GET /groups/:id/projects
-```
-
-Parameters:
-
-- `archived` (optional) - if passed, limit by archived status
-- `visibility` (optional) - if passed, limit by visibility `public`, `internal`, `private`
-- `order_by` (optional) - Return requests ordered by `id`, `name`, `path`, `created_at`, `updated_at` or `last_activity_at` fields. Default is `created_at`
-- `sort` (optional) - Return requests sorted in `asc` or `desc` order. Default is `desc`
-- `search` (optional) - Return list of authorized projects according to a search criteria
-- `ci_enabled_first` - Return projects ordered by ci_enabled flag. Projects with enabled GitLab CI go first
-
-```json
-[
- {
- "id": 9,
- "description": "foo",
- "default_branch": "master",
- "tag_list": [],
- "public": false,
- "archived": false,
- "visibility_level": 10,
- "ssh_url_to_repo": "git@gitlab.example.com/html5-boilerplate.git",
- "http_url_to_repo": "http://gitlab.example.com/h5bp/html5-boilerplate.git",
- "web_url": "http://gitlab.example.com/h5bp/html5-boilerplate",
- "name": "Html5 Boilerplate",
- "name_with_namespace": "Experimental / Html5 Boilerplate",
- "path": "html5-boilerplate",
- "path_with_namespace": "h5bp/html5-boilerplate",
- "issues_enabled": true,
- "merge_requests_enabled": true,
- "wiki_enabled": true,
- "builds_enabled": true,
- "snippets_enabled": true,
- "created_at": "2016-04-05T21:40:50.169Z",
- "last_activity_at": "2016-04-06T16:52:08.432Z",
- "shared_runners_enabled": true,
- "creator_id": 1,
- "namespace": {
- "id": 5,
- "name": "Experimental",
- "path": "h5bp",
- "owner_id": null,
- "created_at": "2016-04-05T21:40:49.152Z",
- "updated_at": "2016-04-07T08:07:48.466Z",
- "description": "foo",
- "avatar": {
- "url": null
- },
- "share_with_group_lock": false,
- "visibility_level": 10
- },
- "avatar_url": null,
- "star_count": 1,
- "forks_count": 0,
- "open_issues_count": 3,
- "public_builds": true,
- "shared_with_groups": []
- }
-]
-```
-
-## Details of a group
-
-Get all details of a group.
-
-```
-GET /groups/:id
-```
-
-Parameters:
-
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer/string | yes | The ID or path of a group |
-
-```bash
-curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/4
-```
-
-Example response:
-
-```json
-{
- "id": 4,
- "name": "Twitter",
- "path": "twitter",
- "description": "Aliquid qui quis dignissimos distinctio ut commodi voluptas est.",
- "visibility_level": 20,
- "avatar_url": null,
- "web_url": "https://gitlab.example.com/groups/twitter",
- "projects": [
- {
- "id": 7,
- "description": "Voluptas veniam qui et beatae voluptas doloremque explicabo facilis.",
- "default_branch": "master",
- "tag_list": [],
- "public": true,
- "archived": false,
- "visibility_level": 20,
- "ssh_url_to_repo": "git@gitlab.example.com:twitter/typeahead-js.git",
- "http_url_to_repo": "https://gitlab.example.com/twitter/typeahead-js.git",
- "web_url": "https://gitlab.example.com/twitter/typeahead-js",
- "name": "Typeahead.Js",
- "name_with_namespace": "Twitter / Typeahead.Js",
- "path": "typeahead-js",
- "path_with_namespace": "twitter/typeahead-js",
- "issues_enabled": true,
- "merge_requests_enabled": true,
- "wiki_enabled": true,
- "builds_enabled": true,
- "snippets_enabled": false,
- "container_registry_enabled": true,
- "created_at": "2016-06-17T07:47:25.578Z",
- "last_activity_at": "2016-06-17T07:47:25.881Z",
- "shared_runners_enabled": true,
- "creator_id": 1,
- "namespace": {
- "id": 4,
- "name": "Twitter",
- "path": "twitter",
- "owner_id": null,
- "created_at": "2016-06-17T07:47:24.216Z",
- "updated_at": "2016-06-17T07:47:24.216Z",
- "description": "Aliquid qui quis dignissimos distinctio ut commodi voluptas est.",
- "avatar": {
- "url": null
- },
- "share_with_group_lock": false,
- "visibility_level": 20
- },
- "avatar_url": null,
- "star_count": 0,
- "forks_count": 0,
- "open_issues_count": 3,
- "public_builds": true,
- "shared_with_groups": []
- },
- {
- "id": 6,
- "description": "Aspernatur omnis repudiandae qui voluptatibus eaque.",
- "default_branch": "master",
- "tag_list": [],
- "public": false,
- "archived": false,
- "visibility_level": 10,
- "ssh_url_to_repo": "git@gitlab.example.com:twitter/flight.git",
- "http_url_to_repo": "https://gitlab.example.com/twitter/flight.git",
- "web_url": "https://gitlab.example.com/twitter/flight",
- "name": "Flight",
- "name_with_namespace": "Twitter / Flight",
- "path": "flight",
- "path_with_namespace": "twitter/flight",
- "issues_enabled": true,
- "merge_requests_enabled": true,
- "wiki_enabled": true,
- "builds_enabled": true,
- "snippets_enabled": false,
- "container_registry_enabled": true,
- "created_at": "2016-06-17T07:47:24.661Z",
- "last_activity_at": "2016-06-17T07:47:24.838Z",
- "shared_runners_enabled": true,
- "creator_id": 1,
- "namespace": {
- "id": 4,
- "name": "Twitter",
- "path": "twitter",
- "owner_id": null,
- "created_at": "2016-06-17T07:47:24.216Z",
- "updated_at": "2016-06-17T07:47:24.216Z",
- "description": "Aliquid qui quis dignissimos distinctio ut commodi voluptas est.",
- "avatar": {
- "url": null
- },
- "share_with_group_lock": false,
- "visibility_level": 20
- },
- "avatar_url": null,
- "star_count": 0,
- "forks_count": 0,
- "open_issues_count": 8,
- "public_builds": true,
- "shared_with_groups": []
- }
- ],
- "shared_projects": [
- {
- "id": 8,
- "description": "Velit eveniet provident fugiat saepe eligendi autem.",
- "default_branch": "master",
- "tag_list": [],
- "public": false,
- "archived": false,
- "visibility_level": 0,
- "ssh_url_to_repo": "git@gitlab.example.com:h5bp/html5-boilerplate.git",
- "http_url_to_repo": "https://gitlab.example.com/h5bp/html5-boilerplate.git",
- "web_url": "https://gitlab.example.com/h5bp/html5-boilerplate",
- "name": "Html5 Boilerplate",
- "name_with_namespace": "H5bp / Html5 Boilerplate",
- "path": "html5-boilerplate",
- "path_with_namespace": "h5bp/html5-boilerplate",
- "issues_enabled": true,
- "merge_requests_enabled": true,
- "wiki_enabled": true,
- "builds_enabled": true,
- "snippets_enabled": false,
- "container_registry_enabled": true,
- "created_at": "2016-06-17T07:47:27.089Z",
- "last_activity_at": "2016-06-17T07:47:27.310Z",
- "shared_runners_enabled": true,
- "creator_id": 1,
- "namespace": {
- "id": 5,
- "name": "H5bp",
- "path": "h5bp",
- "owner_id": null,
- "created_at": "2016-06-17T07:47:26.621Z",
- "updated_at": "2016-06-17T07:47:26.621Z",
- "description": "Id consequatur rem vel qui doloremque saepe.",
- "avatar": {
- "url": null
- },
- "share_with_group_lock": false,
- "visibility_level": 20
- },
- "avatar_url": null,
- "star_count": 0,
- "forks_count": 0,
- "open_issues_count": 4,
- "public_builds": true,
- "shared_with_groups": [
- {
- "group_id": 4,
- "group_name": "Twitter",
- "group_access_level": 30
- },
- {
- "group_id": 3,
- "group_name": "Gitlab Org",
- "group_access_level": 10
- }
- ]
- }
- ]
-}
-```
-
-## New group
-
-Creates a new project group. Available only for users who can create groups.
-
-```
-POST /groups
-```
-
-Parameters:
-
-- `name` (required) - The name of the group
-- `path` (required) - The path of the group
-- `description` (optional) - The group's description
-- `visibility_level` (optional) - The group's visibility. 0 for private, 10 for internal, 20 for public.
-
-## Transfer project to group
-
-Transfer a project to the Group namespace. Available only for admin
-
-```
-POST /groups/:id/projects/:project_id
-```
-
-Parameters:
-
-- `id` (required) - The ID or path of a group
-- `project_id` (required) - The ID of a project
-
-## Update group
-
-Updates the project group. Only available to group owners and administrators.
-
-```
-PUT /groups/:id
-```
-
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer | yes | The ID of the group |
-| `name` | string | no | The name of the group |
-| `path` | string | no | The path of the group |
-| `description` | string | no | The description of the group |
-| `visibility_level` | integer | no | The visibility level of the group. 0 for private, 10 for internal, 20 for public. |
-
-```bash
-curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/groups/5?name=Experimental"
-
-```
-
-Example response:
-
-```json
-{
- "id": 5,
- "name": "Experimental",
- "path": "h5bp",
- "description": "foo",
- "visibility_level": 10,
- "avatar_url": null,
- "web_url": "http://gitlab.example.com/groups/h5bp",
- "projects": [
- {
- "id": 9,
- "description": "foo",
- "default_branch": "master",
- "tag_list": [],
- "public": false,
- "archived": false,
- "visibility_level": 10,
- "ssh_url_to_repo": "git@gitlab.example.com/html5-boilerplate.git",
- "http_url_to_repo": "http://gitlab.example.com/h5bp/html5-boilerplate.git",
- "web_url": "http://gitlab.example.com/h5bp/html5-boilerplate",
- "name": "Html5 Boilerplate",
- "name_with_namespace": "Experimental / Html5 Boilerplate",
- "path": "html5-boilerplate",
- "path_with_namespace": "h5bp/html5-boilerplate",
- "issues_enabled": true,
- "merge_requests_enabled": true,
- "wiki_enabled": true,
- "builds_enabled": true,
- "snippets_enabled": true,
- "created_at": "2016-04-05T21:40:50.169Z",
- "last_activity_at": "2016-04-06T16:52:08.432Z",
- "shared_runners_enabled": true,
- "creator_id": 1,
- "namespace": {
- "id": 5,
- "name": "Experimental",
- "path": "h5bp",
- "owner_id": null,
- "created_at": "2016-04-05T21:40:49.152Z",
- "updated_at": "2016-04-07T08:07:48.466Z",
- "description": "foo",
- "avatar": {
- "url": null
- },
- "share_with_group_lock": false,
- "visibility_level": 10
- },
- "avatar_url": null,
- "star_count": 1,
- "forks_count": 0,
- "open_issues_count": 3,
- "public_builds": true,
- "shared_with_groups": []
- }
- ]
-}
-```
-
-## Remove group
-
-Removes group with all projects inside.
-
-```
-DELETE /groups/:id
-```
-
-Parameters:
-
-- `id` (required) - The ID or path of a user group
-
-## Search for group
-
-Get all groups that match your string in their name or path.
-
-```
-GET /groups?search=foobar
-```
-
-```json
-[
- {
- "id": 1,
- "name": "Foobar Group",
- "path": "foo-bar",
- "description": "An interesting group"
- }
-]
-```
-
-## Group members
-
-**Group access levels**
-
-The group access levels are defined in the `Gitlab::Access` module. Currently, these levels are recognized:
-
-```
-GUEST = 10
-REPORTER = 20
-DEVELOPER = 30
-MASTER = 40
-OWNER = 50
-```
-
-### List group members
-
-Get a list of group members viewable by the authenticated user.
-
-```
-GET /groups/:id/members
-```
-
-```json
-[
- {
- "id": 1,
- "username": "raymond_smith",
- "name": "Raymond Smith",
- "state": "active",
- "created_at": "2012-10-22T14:13:35Z",
- "access_level": 30
- },
- {
- "id": 2,
- "username": "john_doe",
- "name": "John Doe",
- "state": "active",
- "created_at": "2012-10-22T14:13:35Z",
- "access_level": 30
- }
-]
-```
-
-### Add group member
-
-Adds a user to the list of group members.
-
-```
-POST /groups/:id/members
-```
-
-Parameters:
-
-- `id` (required) - The ID or path of a group
-- `user_id` (required) - The ID of a user to add
-- `access_level` (required) - Project access level
-
-### Edit group team member
-
-Updates a group team member to a specified access level.
-
-```
-PUT /groups/:id/members/:user_id
-```
-
-Parameters:
-
-- `id` (required) - The ID of a group
-- `user_id` (required) - The ID of a group member
-- `access_level` (required) - Project access level
-
-### Remove user team member
-
-Removes user from user team.
-
-```
-DELETE /groups/:id/members/:user_id
-```
-
-Parameters:
-
-- `id` (required) - The ID or path of a user group
-- `user_id` (required) - The ID of a group member
-
-## Namespaces in groups
-
-By default, groups only get 20 namespaces at a time because the API results are paginated.
-
-To get more (up to 100), pass the following as an argument to the API call:
-```
-/groups?per_page=100
-```
-
-And to switch pages add:
-```
-/groups?per_page=100&page=2
-```
+# Groups + +## List groups + +Get a list of groups. (As user: my groups, as admin: all groups) + +``` +GET /groups +``` + +```json +[ + { + "id": 1, + "name": "Foobar Group", + "path": "foo-bar", + "description": "An interesting group" + } +] +``` + +You can search for groups by name or path, see below. + + +## List a group's projects + +Get a list of projects in this group. + +``` +GET /groups/:id/projects +``` + +Parameters: + +- `archived` (optional) - if passed, limit by archived status +- `visibility` (optional) - if passed, limit by visibility `public`, `internal`, `private` +- `order_by` (optional) - Return requests ordered by `id`, `name`, `path`, `created_at`, `updated_at` or `last_activity_at` fields. Default is `created_at` +- `sort` (optional) - Return requests sorted in `asc` or `desc` order. Default is `desc` +- `search` (optional) - Return list of authorized projects according to a search criteria +- `ci_enabled_first` - Return projects ordered by ci_enabled flag. Projects with enabled GitLab CI go first + +```json +[ + { + "id": 9, + "description": "foo", + "default_branch": "master", + "tag_list": [], + "public": false, + "archived": false, + "visibility_level": 10, + "ssh_url_to_repo": "git@gitlab.example.com/html5-boilerplate.git", + "http_url_to_repo": "http://gitlab.example.com/h5bp/html5-boilerplate.git", + "web_url": "http://gitlab.example.com/h5bp/html5-boilerplate", + "name": "Html5 Boilerplate", + "name_with_namespace": "Experimental / Html5 Boilerplate", + "path": "html5-boilerplate", + "path_with_namespace": "h5bp/html5-boilerplate", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "builds_enabled": true, + "snippets_enabled": true, + "created_at": "2016-04-05T21:40:50.169Z", + "last_activity_at": "2016-04-06T16:52:08.432Z", + "shared_runners_enabled": true, + "creator_id": 1, + "namespace": { + "id": 5, + "name": "Experimental", + "path": "h5bp", + "owner_id": null, + "created_at": "2016-04-05T21:40:49.152Z", + "updated_at": "2016-04-07T08:07:48.466Z", + "description": "foo", + "avatar": { + "url": null + }, + "share_with_group_lock": false, + "visibility_level": 10 + }, + "avatar_url": null, + "star_count": 1, + "forks_count": 0, + "open_issues_count": 3, + "public_builds": true, + "shared_with_groups": [], + "request_access_enabled": false + } +] +``` + +## Details of a group + +Get all details of a group. + +``` +GET /groups/:id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or path of a group | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/4 +``` + +Example response: + +```json +{ + "id": 4, + "name": "Twitter", + "path": "twitter", + "description": "Aliquid qui quis dignissimos distinctio ut commodi voluptas est.", + "visibility_level": 20, + "avatar_url": null, + "web_url": "https://gitlab.example.com/groups/twitter", + "request_access_enabled": false, + "projects": [ + { + "id": 7, + "description": "Voluptas veniam qui et beatae voluptas doloremque explicabo facilis.", + "default_branch": "master", + "tag_list": [], + "public": true, + "archived": false, + "visibility_level": 20, + "ssh_url_to_repo": "git@gitlab.example.com:twitter/typeahead-js.git", + "http_url_to_repo": "https://gitlab.example.com/twitter/typeahead-js.git", + "web_url": "https://gitlab.example.com/twitter/typeahead-js", + "name": "Typeahead.Js", + "name_with_namespace": "Twitter / Typeahead.Js", + "path": "typeahead-js", + "path_with_namespace": "twitter/typeahead-js", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "builds_enabled": true, + "snippets_enabled": false, + "container_registry_enabled": true, + "created_at": "2016-06-17T07:47:25.578Z", + "last_activity_at": "2016-06-17T07:47:25.881Z", + "shared_runners_enabled": true, + "creator_id": 1, + "namespace": { + "id": 4, + "name": "Twitter", + "path": "twitter", + "owner_id": null, + "created_at": "2016-06-17T07:47:24.216Z", + "updated_at": "2016-06-17T07:47:24.216Z", + "description": "Aliquid qui quis dignissimos distinctio ut commodi voluptas est.", + "avatar": { + "url": null + }, + "share_with_group_lock": false, + "visibility_level": 20 + }, + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "open_issues_count": 3, + "public_builds": true, + "shared_with_groups": [], + "request_access_enabled": false + }, + { + "id": 6, + "description": "Aspernatur omnis repudiandae qui voluptatibus eaque.", + "default_branch": "master", + "tag_list": [], + "public": false, + "archived": false, + "visibility_level": 10, + "ssh_url_to_repo": "git@gitlab.example.com:twitter/flight.git", + "http_url_to_repo": "https://gitlab.example.com/twitter/flight.git", + "web_url": "https://gitlab.example.com/twitter/flight", + "name": "Flight", + "name_with_namespace": "Twitter / Flight", + "path": "flight", + "path_with_namespace": "twitter/flight", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "builds_enabled": true, + "snippets_enabled": false, + "container_registry_enabled": true, + "created_at": "2016-06-17T07:47:24.661Z", + "last_activity_at": "2016-06-17T07:47:24.838Z", + "shared_runners_enabled": true, + "creator_id": 1, + "namespace": { + "id": 4, + "name": "Twitter", + "path": "twitter", + "owner_id": null, + "created_at": "2016-06-17T07:47:24.216Z", + "updated_at": "2016-06-17T07:47:24.216Z", + "description": "Aliquid qui quis dignissimos distinctio ut commodi voluptas est.", + "avatar": { + "url": null + }, + "share_with_group_lock": false, + "visibility_level": 20 + }, + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "open_issues_count": 8, + "public_builds": true, + "shared_with_groups": [], + "request_access_enabled": false + } + ], + "shared_projects": [ + { + "id": 8, + "description": "Velit eveniet provident fugiat saepe eligendi autem.", + "default_branch": "master", + "tag_list": [], + "public": false, + "archived": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@gitlab.example.com:h5bp/html5-boilerplate.git", + "http_url_to_repo": "https://gitlab.example.com/h5bp/html5-boilerplate.git", + "web_url": "https://gitlab.example.com/h5bp/html5-boilerplate", + "name": "Html5 Boilerplate", + "name_with_namespace": "H5bp / Html5 Boilerplate", + "path": "html5-boilerplate", + "path_with_namespace": "h5bp/html5-boilerplate", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "builds_enabled": true, + "snippets_enabled": false, + "container_registry_enabled": true, + "created_at": "2016-06-17T07:47:27.089Z", + "last_activity_at": "2016-06-17T07:47:27.310Z", + "shared_runners_enabled": true, + "creator_id": 1, + "namespace": { + "id": 5, + "name": "H5bp", + "path": "h5bp", + "owner_id": null, + "created_at": "2016-06-17T07:47:26.621Z", + "updated_at": "2016-06-17T07:47:26.621Z", + "description": "Id consequatur rem vel qui doloremque saepe.", + "avatar": { + "url": null + }, + "share_with_group_lock": false, + "visibility_level": 20 + }, + "avatar_url": null, + "star_count": 0, + "forks_count": 0, + "open_issues_count": 4, + "public_builds": true, + "shared_with_groups": [ + { + "group_id": 4, + "group_name": "Twitter", + "group_access_level": 30 + }, + { + "group_id": 3, + "group_name": "Gitlab Org", + "group_access_level": 10 + } + ] + } + ] +} +``` + +## New group + +Creates a new project group. Available only for users who can create groups. + +``` +POST /groups +``` + +Parameters: + +- `name` (required) - The name of the group +- `path` (required) - The path of the group +- `description` (optional) - The group's description +- `visibility_level` (optional) - The group's visibility. 0 for private, 10 for internal, 20 for public. +- `lfs_enabled` (optional) - Enable/disable Large File Storage (LFS) for the projects in this group +- `request_access_enabled` (optional) - Allow users to request member access. + +## Transfer project to group + +Transfer a project to the Group namespace. Available only for admin + +``` +POST /groups/:id/projects/:project_id +``` + +Parameters: + +- `id` (required) - The ID or path of a group +- `project_id` (required) - The ID of a project + +## Update group + +Updates the project group. Only available to group owners and administrators. + +``` +PUT /groups/:id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the group | +| `name` | string | no | The name of the group | +| `path` | string | no | The path of the group | +| `description` | string | no | The description of the group | +| `visibility_level` | integer | no | The visibility level of the group. 0 for private, 10 for internal, 20 for public. | +| `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group | +| `request_access_enabled` | boolean | no | Allow users to request member access. | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/groups/5?name=Experimental" + +``` + +Example response: + +```json +{ + "id": 5, + "name": "Experimental", + "path": "h5bp", + "description": "foo", + "visibility_level": 10, + "avatar_url": null, + "web_url": "http://gitlab.example.com/groups/h5bp", + "request_access_enabled": false, + "projects": [ + { + "id": 9, + "description": "foo", + "default_branch": "master", + "tag_list": [], + "public": false, + "archived": false, + "visibility_level": 10, + "ssh_url_to_repo": "git@gitlab.example.com/html5-boilerplate.git", + "http_url_to_repo": "http://gitlab.example.com/h5bp/html5-boilerplate.git", + "web_url": "http://gitlab.example.com/h5bp/html5-boilerplate", + "name": "Html5 Boilerplate", + "name_with_namespace": "Experimental / Html5 Boilerplate", + "path": "html5-boilerplate", + "path_with_namespace": "h5bp/html5-boilerplate", + "issues_enabled": true, + "merge_requests_enabled": true, + "wiki_enabled": true, + "builds_enabled": true, + "snippets_enabled": true, + "created_at": "2016-04-05T21:40:50.169Z", + "last_activity_at": "2016-04-06T16:52:08.432Z", + "shared_runners_enabled": true, + "creator_id": 1, + "namespace": { + "id": 5, + "name": "Experimental", + "path": "h5bp", + "owner_id": null, + "created_at": "2016-04-05T21:40:49.152Z", + "updated_at": "2016-04-07T08:07:48.466Z", + "description": "foo", + "avatar": { + "url": null + }, + "share_with_group_lock": false, + "visibility_level": 10 + }, + "avatar_url": null, + "star_count": 1, + "forks_count": 0, + "open_issues_count": 3, + "public_builds": true, + "shared_with_groups": [], + "request_access_enabled": false + } + ] +} +``` + +## Remove group + +Removes group with all projects inside. + +``` +DELETE /groups/:id +``` + +Parameters: + +- `id` (required) - The ID or path of a user group + +## Search for group + +Get all groups that match your string in their name or path. + +``` +GET /groups?search=foobar +``` + +```json +[ + { + "id": 1, + "name": "Foobar Group", + "path": "foo-bar", + "description": "An interesting group" + } +] +``` + +## Group members + +Please consult the [Group Members](members.md) documentation. + +## Namespaces in groups + +By default, groups only get 20 namespaces at a time because the API results are paginated. + +To get more (up to 100), pass the following as an argument to the API call: +``` +/groups?per_page=100 +``` + +And to switch pages add: +``` +/groups?per_page=100&page=2 +``` diff --git a/doc/api/issues.md b/doc/api/issues.md index 419fb8f85d8..eed0d2fce51 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -33,7 +33,7 @@ GET /issues?labels=foo,bar&state=opened | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/issues +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/issues ``` Example response: @@ -79,7 +79,9 @@ Example response: "labels" : [], "subscribed" : false, "user_notes_count": 1, - "due_date": "2016-07-22" + "due_date": "2016-07-22", + "web_url": "http://example.com/example/example/issues/6", + "confidential": false } ] ``` @@ -110,7 +112,7 @@ GET /groups/:id/issues?milestone=1.0.0&state=opened ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/4/issues +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/4/issues ``` Example response: @@ -156,7 +158,9 @@ Example response: "created_at" : "2016-01-04T15:31:46.176Z", "subscribed" : false, "user_notes_count": 1, - "due_date": null + "due_date": null, + "web_url": "http://example.com/example/example/issues/1", + "confidential": false } ] ``` @@ -189,7 +193,7 @@ GET /projects/:id/issues?iid=42 ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues ``` Example response: @@ -235,7 +239,9 @@ Example response: "created_at" : "2016-01-04T15:31:46.176Z", "subscribed" : false, "user_notes_count": 1, - "due_date": "2016-07-22" + "due_date": "2016-07-22", + "web_url": "http://example.com/example/example/issues/1", + "confidential": false } ] ``` @@ -254,7 +260,7 @@ GET /projects/:id/issues/:issue_id | `issue_id`| integer | yes | The ID of a project's issue | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/41 +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/41 ``` Example response: @@ -299,7 +305,9 @@ Example response: "created_at" : "2016-01-04T15:31:46.176Z", "subscribed": false, "user_notes_count": 1, - "due_date": null + "due_date": null, + "web_url": "http://example.com/example/example/issues/1", + "confidential": false } ``` @@ -320,14 +328,15 @@ POST /projects/:id/issues | `id` | integer | yes | The ID of a project | | `title` | string | yes | The title of an issue | | `description` | string | no | The description of an issue | +| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | | `assignee_id` | integer | no | The ID of a user to assign issue | | `milestone_id` | integer | no | The ID of a milestone to assign issue | | `labels` | string | no | Comma-separated label names for an issue | -| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` | -| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | +| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues?title=Issues%20with%20auth&labels=bug +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues?title=Issues%20with%20auth&labels=bug ``` Example response: @@ -357,7 +366,9 @@ Example response: "milestone" : null, "subscribed" : true, "user_notes_count": 0, - "due_date": null + "due_date": null, + "web_url": "http://example.com/example/example/issues/14", + "confidential": false } ``` @@ -380,15 +391,16 @@ PUT /projects/:id/issues/:issue_id | `issue_id` | integer | yes | The ID of a project's issue | | `title` | string | no | The title of an issue | | `description` | string | no | The description of an issue | +| `confidential` | boolean | no | Updates an issue to be confidential | | `assignee_id` | integer | no | The ID of a user to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `labels` | string | no | Comma-separated label names for an issue | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | -| `updated_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` | -| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | +| `updated_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | +| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | ```bash -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85?state_event=close +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85?state_event=close ``` Example response: @@ -418,7 +430,9 @@ Example response: "milestone" : null, "subscribed" : true, "user_notes_count": 0, - "due_date": "2016-07-22" + "due_date": "2016-07-22", + "web_url": "http://example.com/example/example/issues/15", + "confidential": false } ``` @@ -438,7 +452,7 @@ DELETE /projects/:id/issues/:issue_id | `issue_id` | integer | yes | The ID of a project's issue | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85 ``` ## Move an issue @@ -463,7 +477,7 @@ POST /projects/:id/issues/:issue_id/move | `to_project_id` | integer | yes | The ID of the new project | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85/move +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85/move ``` Example response: @@ -496,7 +510,9 @@ Example response: "avatar_url": "http://www.gravatar.com/avatar/7a190fecbaa68212a4b68aeb6e3acd10?s=80&d=identicon", "web_url": "https://gitlab.example.com/u/solon.cremin" }, - "due_date": null + "due_date": null, + "web_url": "http://example.com/example/example/issues/11", + "confidential": false } ``` @@ -518,7 +534,7 @@ POST /projects/:id/issues/:issue_id/subscription | `issue_id` | integer | yes | The ID of a project's issue | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/subscription +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/subscription ``` Example response: @@ -551,7 +567,9 @@ Example response: "avatar_url": "http://www.gravatar.com/avatar/7a190fecbaa68212a4b68aeb6e3acd10?s=80&d=identicon", "web_url": "https://gitlab.example.com/u/solon.cremin" }, - "due_date": null + "due_date": null, + "web_url": "http://example.com/example/example/issues/11", + "confidential": false } ``` @@ -573,7 +591,7 @@ DELETE /projects/:id/issues/:issue_id/subscription | `issue_id` | integer | yes | The ID of a project's issue | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/subscription +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/subscription ``` Example response: @@ -607,7 +625,9 @@ Example response: "web_url": "https://gitlab.example.com/u/orville" }, "subscribed": false, - "due_date": null + "due_date": null, + "web_url": "http://example.com/example/example/issues/12", + "confidential": false } ``` @@ -628,7 +648,7 @@ POST /projects/:id/issues/:issue_id/todo | `issue_id` | integer | yes | The ID of a project's issue | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/todo +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/todo ``` Example response: @@ -693,7 +713,10 @@ Example response: "subscribed": true, "user_notes_count": 7, "upvotes": 0, - "downvotes": 0 + "downvotes": 0, + "due_date": null, + "web_url": "http://example.com/example/example/issues/110", + "confidential": false }, "target_url": "https://gitlab.example.com/gitlab-org/gitlab-ci/issues/10", "body": "Vel voluptas atque dicta mollitia adipisci qui at.", diff --git a/doc/api/labels.md b/doc/api/labels.md index a181c0f57a2..656232cc940 100644 --- a/doc/api/labels.md +++ b/doc/api/labels.md @@ -13,7 +13,7 @@ GET /projects/:id/labels | `id` | integer | yes | The ID of the project | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/labels +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/labels ``` Example response: @@ -82,7 +82,7 @@ POST /projects/:id/labels | `description` | string | no | The description of the label | ```bash -curl --data "name=feature&color=#5843AD" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels" +curl --data "name=feature&color=#5843AD" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels" ``` Example response: @@ -113,7 +113,7 @@ DELETE /projects/:id/labels | `name` | string | yes | The name of the label | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels?name=bug" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels?name=bug" ``` Example response: @@ -148,12 +148,12 @@ PUT /projects/:id/labels | --------------- | ------- | --------------------------------- | ------------------------------- | | `id` | integer | yes | The ID of the project | | `name` | string | yes | The name of the existing label | -| `new_name` | string | yes if `color` if not provided | The new name of the label | +| `new_name` | string | yes if `color` is not provided | The new name of the label | | `color` | string | yes if `new_name` is not provided | The new color of the label in 6-digit hex notation with leading `#` sign | | `description` | string | no | The new description of the label | ```bash -curl -X PUT --data "name=documentation&new_name=docs&color=#8E44AD&description=Documentation" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels" +curl --request PUT --data "name=documentation&new_name=docs&color=#8E44AD&description=Documentation" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/labels" ``` Example response: @@ -184,7 +184,7 @@ POST /projects/:id/labels/:label_id/subscription | `label_id` | integer or string | yes | The ID or title of a project's label | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/labels/1/subscription +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/labels/1/subscription ``` Example response: @@ -219,7 +219,7 @@ DELETE /projects/:id/labels/:label_id/subscription | `label_id` | integer or string | yes | The ID or title of a project's label | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/labels/1/subscription +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/labels/1/subscription ``` Example response: diff --git a/doc/api/members.md b/doc/api/members.md new file mode 100644 index 00000000000..6535e9a7801 --- /dev/null +++ b/doc/api/members.md @@ -0,0 +1,185 @@ +# Group and project members + +**Valid access levels** + +The access levels are defined in the `Gitlab::Access` module. Currently, these levels are recognized: + +``` +10 => Guest access +20 => Reporter access +30 => Developer access +40 => Master access +50 => Owner access # Only valid for groups +``` + +## List all members of a group or project + +Gets a list of group or project members viewable by the authenticated user. + +Returns `200` if the request succeeds. + +``` +GET /groups/:id/members +GET /projects/:id/members +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `query` | string | no | A query string to search for members | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members +``` + +Example response: + +```json +[ + { + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 + }, + { + "id": 2, + "username": "john_doe", + "name": "John Doe", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 + } +] +``` + +## Get a member of a group or project + +Gets a member of a group or project. + +Returns `200` if the request succeeds. + +``` +GET /groups/:id/members/:user_id +GET /projects/:id/members/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30, + "expires_at": null +} +``` + +## Add a member to a group or project + +Adds a member to a group or project. + +Returns `201` if the request succeeds. + +``` +POST /groups/:id/members +POST /projects/:id/members +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the new member | +| `access_level` | integer | yes | A valid access level | +| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "user_id=1&access_level=30" https://gitlab.example.com/api/v3/groups/:id/members +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "user_id=1&access_level=30" https://gitlab.example.com/api/v3/projects/:id/members +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 30 +} +``` + +## Edit a member of a group or project + +Updates a member of a group or project. + +Returns `200` if the request succeeds. + +``` +PUT /groups/:id/members/:user_id +PUT /projects/:id/members/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the member | +| `access_level` | integer | yes | A valid access level | +| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id?access_level=40 +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id?access_level=40 +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "created_at": "2012-10-22T14:13:35Z", + "access_level": 40 +} +``` + +## Remove a member from a group or project + +Removes a user from a group or project. + +Returns `200` if the request succeeds. + +``` +DELETE /groups/:id/members/:user_id +DELETE /projects/:id/members/:user_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/:id/members/:user_id +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/members/:user_id +``` diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 8dde754663e..7ba2b74c7c3 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -76,9 +76,12 @@ curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/p "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : false, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null, "user_notes_count": 1, "should_remove_source_branch": true, "force_remove_source_branch": true + "web_url": "http://example.com/example/example/merge_requests/1" } ] ``` @@ -148,9 +151,12 @@ curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/p "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": "9999999999999999999999999999999999999999", "user_notes_count": 1, "should_remove_source_branch": true, - "force_remove_source_branch": true + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -261,9 +267,12 @@ curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/api/v3/p "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null, "user_notes_count": 1, "should_remove_source_branch": true, - "force_remove_source_branch": true, + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1", "changes": [ { "old_path": "VERSION", @@ -352,9 +361,12 @@ curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/ "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null, "user_notes_count": 0, "should_remove_source_branch": true, - "force_remove_source_branch": false + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -433,9 +445,12 @@ curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/a "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null, "user_notes_count": 1, "should_remove_source_branch": true, - "force_remove_source_branch": true + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -458,7 +473,7 @@ DELETE /projects/:id/merge_requests/:merge_request_id | `merge_request_id` | integer | yes | The ID of a project's merge request | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/merge_request/85 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/merge_request/85 ``` ## Accept MR @@ -540,9 +555,12 @@ curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/ "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": "9999999999999999999999999999999999999999", "user_notes_count": 1, "should_remove_source_branch": true, - "force_remove_source_branch": true + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -618,9 +636,12 @@ curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" http://gitlab.example.com/a "merge_when_build_succeeds": true, "merge_status": "can_be_merged", "subscribed" : true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null, "user_notes_count": 1, "should_remove_source_branch": true, - "force_remove_source_branch": true + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1" } ``` @@ -642,7 +663,7 @@ GET /projects/:id/merge_requests/:merge_request_id/closes_issues | `merge_request_id` | integer | yes | The ID of the merge request | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/76/merge_requests/1/closes_issues +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/76/merge_requests/1/closes_issues ``` Example response when the GitLab issue tracker is used: @@ -720,7 +741,7 @@ POST /projects/:id/merge_requests/:merge_request_id/subscription | `merge_request_id` | integer | yes | The ID of the merge request | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/17/subscription +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/17/subscription ``` Example response: @@ -772,7 +793,9 @@ Example response: }, "merge_when_build_succeeds": false, "merge_status": "cannot_be_merged", - "subscribed": true + "subscribed": true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null } ``` @@ -794,7 +817,7 @@ DELETE /projects/:id/merge_requests/:merge_request_id/subscription | `merge_request_id` | integer | yes | The ID of the merge request | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/17/subscription +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/17/subscription ``` Example response: @@ -846,7 +869,9 @@ Example response: }, "merge_when_build_succeeds": false, "merge_status": "cannot_be_merged", - "subscribed": false + "subscribed": false, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null } ``` @@ -867,7 +892,7 @@ POST /projects/:id/merge_requests/:merge_request_id/todo | `merge_request_id` | integer | yes | The ID of the merge request | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/27/todo +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/27/todo ``` Example response: @@ -939,9 +964,12 @@ Example response: "merge_when_build_succeeds": false, "merge_status": "unchecked", "subscribed": true, + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": null, "user_notes_count": 7, "should_remove_source_branch": true, - "force_remove_source_branch": true + "force_remove_source_branch": false, + "web_url": "http://example.com/example/example/merge_requests/1" }, "target_url": "https://gitlab.example.com/gitlab-org/gitlab-ci/merge_requests/7", "body": "Et voluptas laudantium minus nihil recusandae ut accusamus earum aut non.", @@ -949,3 +977,112 @@ Example response: "created_at": "2016-07-01T11:14:15.530Z" } ``` + +## Get MR diff versions + +Get a list of merge request diff versions. + +``` +GET /projects/:id/merge_requests/:merge_request_id/versions +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | String | yes | The ID of the project | +| `merge_request_id` | integer | yes | The ID of the merge request | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/merge_requests/1/versions +``` + +Example response: + +```json +[{ + "id": 110, + "head_commit_sha": "33e2ee8579fda5bc36accc9c6fbd0b4fefda9e30", + "base_commit_sha": "eeb57dffe83deb686a60a71c16c32f71046868fd", + "start_commit_sha": "eeb57dffe83deb686a60a71c16c32f71046868fd", + "created_at": "2016-07-26T14:44:48.926Z", + "merge_request_id": 105, + "state": "collected", + "real_size": "1" +}, { + "id": 108, + "head_commit_sha": "3eed087b29835c48015768f839d76e5ea8f07a24", + "base_commit_sha": "eeb57dffe83deb686a60a71c16c32f71046868fd", + "start_commit_sha": "eeb57dffe83deb686a60a71c16c32f71046868fd", + "created_at": "2016-07-25T14:21:33.028Z", + "merge_request_id": 105, + "state": "collected", + "real_size": "1" +}] +``` + +## Get a single MR diff version + +Get a single merge request diff version. + +``` +GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | String | yes | The ID of the project | +| `merge_request_id` | integer | yes | The ID of the merge request | +| `version_id` | integer | yes | The ID of the merge request diff version | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/merge_requests/1/versions/1 +``` + +Example response: + +```json +{ + "id": 110, + "head_commit_sha": "33e2ee8579fda5bc36accc9c6fbd0b4fefda9e30", + "base_commit_sha": "eeb57dffe83deb686a60a71c16c32f71046868fd", + "start_commit_sha": "eeb57dffe83deb686a60a71c16c32f71046868fd", + "created_at": "2016-07-26T14:44:48.926Z", + "merge_request_id": 105, + "state": "collected", + "real_size": "1", + "commits": [{ + "id": "33e2ee8579fda5bc36accc9c6fbd0b4fefda9e30", + "short_id": "33e2ee85", + "title": "Change year to 2018", + "author_name": "Administrator", + "author_email": "admin@example.com", + "created_at": "2016-07-26T17:44:29.000+03:00", + "message": "Change year to 2018" + }, { + "id": "aa24655de48b36335556ac8a3cd8bb521f977cbd", + "short_id": "aa24655d", + "title": "Update LICENSE", + "author_name": "Administrator", + "author_email": "admin@example.com", + "created_at": "2016-07-25T17:21:53.000+03:00", + "message": "Update LICENSE" + }, { + "id": "3eed087b29835c48015768f839d76e5ea8f07a24", + "short_id": "3eed087b", + "title": "Add license", + "author_name": "Administrator", + "author_email": "admin@example.com", + "created_at": "2016-07-25T17:21:20.000+03:00", + "message": "Add license" + }], + "diffs": [{ + "old_path": "LICENSE", + "new_path": "LICENSE", + "a_mode": "0", + "b_mode": "100644", + "diff": "--- /dev/null\n+++ b/LICENSE\n@@ -0,0 +1,21 @@\n+The MIT License (MIT)\n+\n+Copyright (c) 2018 Administrator\n+\n+Permission is hereby granted, free of charge, to any person obtaining a copy\n+of this software and associated documentation files (the \"Software\"), to deal\n+in the Software without restriction, including without limitation the rights\n+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n+copies of the Software, and to permit persons to whom the Software is\n+furnished to do so, subject to the following conditions:\n+\n+The above copyright notice and this permission notice shall be included in all\n+copies or substantial portions of the Software.\n+\n+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n+SOFTWARE.\n", + "new_file": true, + "renamed_file": false, + "deleted_file": false + }] +} +``` diff --git a/doc/api/milestones.md b/doc/api/milestones.md index e4202025f80..ae7d22a4be5 100644 --- a/doc/api/milestones.md +++ b/doc/api/milestones.md @@ -20,7 +20,7 @@ Parameters: | `state` | string | optional | Return only `active` or `closed` milestones` | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/milestones +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/milestones ``` Example Response: diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md index 42d9ce3d391..88cd407d792 100644 --- a/doc/api/namespaces.md +++ b/doc/api/namespaces.md @@ -19,7 +19,7 @@ GET /namespaces Example request: ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/namespaces +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/namespaces ``` Example response: @@ -54,7 +54,7 @@ GET /namespaces?search=foobar Example request: ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/namespaces?search=twitter +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/namespaces?search=twitter ``` Example response: diff --git a/doc/api/notes.md b/doc/api/notes.md index 7aa1c2155bf..572844b8b3f 100644 --- a/doc/api/notes.md +++ b/doc/api/notes.md @@ -78,7 +78,8 @@ Parameters: ### Create new issue note -Creates a new note to a single project issue. +Creates a new note to a single project issue. If you create a note where the body +only contains an Award Emoji, you'll receive this object back. ``` POST /projects/:id/issues/:issue_id/notes @@ -124,7 +125,7 @@ Parameters: | `note_id` | integer | yes | The ID of a note | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/11/notes/636 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/11/notes/636 ``` Example Response: @@ -204,6 +205,7 @@ Parameters: ### Create new snippet note Creates a new note for a single snippet. Snippet notes are comments users can post to a snippet. +If you create a note where the body only contains an Award Emoji, you'll receive this object back. ``` POST /projects/:id/snippets/:snippet_id/notes @@ -248,7 +250,7 @@ Parameters: | `note_id` | integer | yes | The ID of a note | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/snippets/52/notes/1659 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/snippets/52/notes/1659 ``` Example Response: @@ -332,6 +334,8 @@ Parameters: ### Create new merge request note Creates a new note for a single merge request. +If you create a note where the body only contains an Award Emoji, you'll receive +this object back. ``` POST /projects/:id/merge_requests/:merge_request_id/notes @@ -376,7 +380,7 @@ Parameters: | `note_id` | integer | yes | The ID of a note | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/7/notes/1602 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/7/notes/1602 ``` Example Response: diff --git a/doc/api/notification_settings.md b/doc/api/notification_settings.md new file mode 100644 index 00000000000..ff6c9e4931c --- /dev/null +++ b/doc/api/notification_settings.md @@ -0,0 +1,169 @@ +# Notification settings + +>**Note:** This feature was [introduced][ce-5632] in GitLab 8.12. + +**Valid notification levels** + +The notification levels are defined in the `NotificationSetting::level` model enumeration. Currently, these levels are recognized: + +``` +disabled +participating +watch +global +mention +custom +``` + +If the `custom` level is used, specific email events can be controlled. Notification email events are defined in the `NotificationSetting::EMAIL_EVENTS` model variable. Currently, these events are recognized: + +``` +new_note +new_issue +reopen_issue +close_issue +reassign_issue +new_merge_request +reopen_merge_request +close_merge_request +reassign_merge_request +merge_merge_request +``` + +## Global notification settings + +Get current notification settings and email address. + +``` +GET /notification_settings +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/notification_settings +``` + +Example response: + +```json +{ + "level": "participating", + "notification_email": "admin@example.com" +} +``` + +## Update global notification settings + +Update current notification settings and email address. + +``` +PUT /notification_settings +``` + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/notification_settings?level=watch +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `level` | string | no | The global notification level | +| `notification_email` | string | no | The email address to send notifications | +| `new_note` | boolean | no | Enable/disable this notification | +| `new_issue` | boolean | no | Enable/disable this notification | +| `reopen_issue` | boolean | no | Enable/disable this notification | +| `close_issue` | boolean | no | Enable/disable this notification | +| `reassign_issue` | boolean | no | Enable/disable this notification | +| `new_merge_request` | boolean | no | Enable/disable this notification | +| `reopen_merge_request` | boolean | no | Enable/disable this notification | +| `close_merge_request` | boolean | no | Enable/disable this notification | +| `reassign_merge_request` | boolean | no | Enable/disable this notification | +| `merge_merge_request` | boolean | no | Enable/disable this notification | + +Example response: + +```json +{ + "level": "watch", + "notification_email": "admin@example.com" +} +``` + +## Group / project level notification settings + +Get current group or project notification settings. + +``` +GET /groups/:id/notification_settings +GET /projects/:id/notification_settings +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/5/notification_settings +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/8/notification_settings +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | + +Example response: + +```json +{ + "level": "global" +} +``` + +## Update group/project level notification settings + +Update current group/project notification settings. + +``` +PUT /groups/:id/notification_settings +PUT /projects/:id/notification_settings +``` + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/5/notification_settings?level=watch +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/8/notification_settings?level=custom&new_note=true +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The group/project ID or path | +| `level` | string | no | The global notification level | +| `new_note` | boolean | no | Enable/disable this notification | +| `new_issue` | boolean | no | Enable/disable this notification | +| `reopen_issue` | boolean | no | Enable/disable this notification | +| `close_issue` | boolean | no | Enable/disable this notification | +| `reassign_issue` | boolean | no | Enable/disable this notification | +| `new_merge_request` | boolean | no | Enable/disable this notification | +| `reopen_merge_request` | boolean | no | Enable/disable this notification | +| `close_merge_request` | boolean | no | Enable/disable this notification | +| `reassign_merge_request` | boolean | no | Enable/disable this notification | +| `merge_merge_request` | boolean | no | Enable/disable this notification | + +Example responses: + +```json +{ + "level": "watch" +} + +{ + "level": "custom", + "events": { + "new_note": true, + "new_issue": false, + "reopen_issue": false, + "close_issue": false, + "reassign_issue": false, + "new_merge_request": false, + "reopen_merge_request": false, + "close_merge_request": false, + "reassign_merge_request": false, + "merge_merge_request": false + } +} +``` + +[ce-5632]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5632 diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index 7ce89adc98b..5ef5e3f5744 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -1,38 +1,59 @@ -# GitLab as an OAuth2 client +# GitLab as an OAuth2 provider -This document is about using other OAuth authentication service providers to sign into GitLab. -If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [Oauth2 provider documentation](../integration/oauth_provider.md). +This document covers using the OAuth2 protocol to access GitLab. -OAuth2 is a protocol that enables us to authenticate a user without requiring them to give their password. +If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [Oauth2 provider documentation](../integration/oauth_provider.md). -Before using the OAuth2 you should create an application in user's account. Each application gets a unique App ID and App Secret parameters. You should not share these. +OAuth2 is a protocol that enables us to authenticate a user without requiring them to give their password to a third-party. This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper) ## Web Application Flow -This flow is using for authentication from third-party web sites and is probably used the most. -It basically consists of an exchange of an authorization token for an access token. For more detailed info, check out the [RFC spec here](http://tools.ietf.org/html/rfc6749#section-4.1) +This is the most common type of flow and is used by server-side clients that wish to access GitLab on a user's behalf. + +>**Note:** +This flow **should not** be used for client-side clients as you would leak your `client_secret`. Client-side clients should use the Implicit Grant (which is currently unsupported). -This flow consists from 3 steps. +For more detailed information, check out the [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1) + +In the following sections you will be introduced to the three steps needed for this flow. ### 1. Registering the client -Create an application in user's account profile. +First, you should create an application (`/profile/applications`) in your user's account. +Each application gets a unique App ID and App Secret parameters. + +>**Note:** +**You should not share/leak your App ID or App Secret.** ### 2. Requesting authorization -To request the authorization token, you should visit the `/oauth/authorize` endpoint. You can do that by visiting manually the URL: +To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint: + +``` +https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=your_unique_state_hash +``` + +This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. + +The redirect will include the GET `code` parameter, for example: ``` -http://localhost:3000/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code +http://myapp.com/oauth/redirect?code=1234567890&state=your_unique_state_hash ``` -Where REDIRECT_URI is the URL in your app where users will be sent after authorization. +You should then use the `code` to request an access token. + +>**Important:** +It is highly recommended that you send a `state` value with the request to `/oauth/authorize` and +validate that value is returned and matches in the redirect request. +This is important to prevent [CSRF attacks](http://www.oauthsecurity.com/#user-content-authorization-code-flow), +`state` really should have been a requirement in the standard! ### 3. Requesting the access token -To request the access token, you should use the returned code and exchange it for an access token. To do that you can use any HTTP client. In this case, I used rest-client: +Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example, we are using Ruby's `rest-client`: ``` parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' @@ -41,11 +62,13 @@ RestClient.post 'http://localhost:3000/oauth/token', parameters # The response will be { "access_token": "de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54", - "token_type": "bearer", + "token_type": "bearer", "expires_in": 7200, "refresh_token": "8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1" } ``` +>**Note:** +The `redirect_uri` must match the `redirect_uri` used in the original authorization request. You can now make requests to the API with the access token returned. @@ -60,23 +83,26 @@ GET https://localhost:3000/api/v3/user?access_token=OAUTH-TOKEN Or you can put the token to the Authorization header: ``` -curl -H "Authorization: Bearer OAUTH-TOKEN" https://localhost:3000/api/v3/user +curl --header "Authorization: Bearer OAUTH-TOKEN" https://localhost:3000/api/v3/user ``` ## Resource Owner Password Credentials ## Deprecation Notice -1. Starting in GitLab 9.0, the Resource Owner Password Credentials will be *disabled* for users with two-factor authentication turned on. +1. Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication turned on. 2. These users can access the API using [personal access tokens] instead. --- -In this flow, a token is requested in exchange for the resource owner credentials (username and password). +In this flow, a token is requested in exchange for the resource owner credentials (username and password). The credentials should only be used when there is a high degree of trust between the resource owner and the client (e.g. the client is part of the device operating system or a highly privileged application), and when other authorization grant types are not available (such as an authorization code). +>**Important:** +Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens] are a better choice. + Even though this grant type requires direct client access to the resource owner credentials, the resource owner credentials are used for a single request and are exchanged for an access token. This grant type can eliminate the need for the client to store the resource owner credentials for future use, by exchanging the credentials with a long-lived access token or refresh token. @@ -86,7 +112,7 @@ You can do POST request to `/oauth/token` with parameters: { "grant_type" : "password", "username" : "user@example.com", - "password" : "sekret" + "password" : "secret" } ``` @@ -104,8 +130,8 @@ For testing you can use the oauth2 ruby gem: ``` client = OAuth2::Client.new('the_client_id', 'the_client_secret', :site => "http://example.com") -access_token = client.password.get_token('user@example.com', 'sekret') +access_token = client.password.get_token('user@example.com', 'secret') puts access_token.token ``` -[personal access tokens]: ./README.md#personal-access-tokens +[personal access tokens]: ./README.md#personal-access-tokens
\ No newline at end of file diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md new file mode 100644 index 00000000000..847408a7f61 --- /dev/null +++ b/doc/api/pipelines.md @@ -0,0 +1,207 @@ +# Pipelines API + +## List project pipelines + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +GET /projects/:id/pipelines +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines" +``` + +Example of response + +```json +[ + { + "id": 47, + "status": "pending", + "ref": "new-pipeline", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-16T10:23:19.007Z", + "updated_at": "2016-08-16T10:23:19.216Z", + "started_at": null, + "finished_at": null, + "committed_at": null, + "duration": null + }, + { + "id": 48, + "status": "pending", + "ref": "new-pipeline", + "sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a", + "before_sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-16T10:23:21.184Z", + "updated_at": "2016-08-16T10:23:21.314Z", + "started_at": null, + "finished_at": null, + "committed_at": null, + "duration": null + } +] +``` + +## Get a single pipeline + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +GET /projects/:id/pipelines/:pipeline_id +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `pipeline_id` | integer | yes | The ID of a pipeline | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipeline/46" +``` + +Example of response + +```json +{ + "id": 46, + "status": "success", + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "committed_at": null, + "duration": null +} +``` + +## Retry failed builds in a pipeline + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +POST /projects/:id/pipelines/:pipeline_id/retry +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `pipeline_id` | integer | yes | The ID of a pipeline | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines/46/retry" +``` + +Response: + +```json +{ + "id": 46, + "status": "pending", + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "committed_at": null, + "duration": null +} +``` + +## Cancel a pipelines builds + +> [Introduced][ce-5837] in GitLab 8.11 + +``` +POST /projects/:id/pipelines/:pipeline_id/cancel +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `pipeline_id` | integer | yes | The ID of a pipeline | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines/46/cancel" +``` + +Response: + +```json +{ + "id": 46, + "status": "canceled", + "ref": "master", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "started_at": null, + "finished_at": "2016-08-11T11:32:35.145Z", + "committed_at": null, + "duration": null +} +``` + +[ce-5837]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5837 diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md index a7acf37b5bc..c6685f54a9d 100644 --- a/doc/api/project_snippets.md +++ b/doc/api/project_snippets.md @@ -53,7 +53,8 @@ Parameters: }, "expires_at": null, "updated_at": "2012-06-28T10:52:04Z", - "created_at": "2012-06-28T10:52:04Z" + "created_at": "2012-06-28T10:52:04Z", + "web_url": "http://example.com/example/example/snippets/1" } ``` diff --git a/doc/api/projects.md b/doc/api/projects.md index 0ba0bffb4ac..f96bf7f6d63 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -20,7 +20,7 @@ Constants for project visibility levels are next: ## List projects -Get a list of projects accessible by the authenticated user. +Get a list of projects for which the authenticated user is a member. ``` GET /projects @@ -28,11 +28,150 @@ GET /projects Parameters: -- `archived` (optional) - if passed, limit by archived status -- `visibility` (optional) - if passed, limit by visibility `public`, `internal`, `private` -- `order_by` (optional) - Return requests ordered by `id`, `name`, `path`, `created_at`, `updated_at` or `last_activity_at` fields. Default is `created_at` -- `sort` (optional) - Return requests sorted in `asc` or `desc` order. Default is `desc` -- `search` (optional) - Return list of authorized projects according to a search criteria +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `archived` | boolean | no | Limit by archived status | +| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | +| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | +| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Return list of authorized projects matching the search criteria | +| `simple` | boolean | no | Return only the ID, URL, name, and path of each project | + +```json +[ + { + "id": 4, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", + "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", + "web_url": "http://example.com/diaspora/diaspora-client", + "tag_list": [ + "example", + "disapora client" + ], + "owner": { + "id": 3, + "name": "Diaspora", + "created_at": "2013-09-30T13:46:02Z" + }, + "name": "Diaspora Client", + "name_with_namespace": "Diaspora / Diaspora Client", + "path": "diaspora-client", + "path_with_namespace": "diaspora/diaspora-client", + "issues_enabled": true, + "open_issues_count": 1, + "merge_requests_enabled": true, + "builds_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "container_registry_enabled": false, + "created_at": "2013-09-30T13:46:02Z", + "last_activity_at": "2013-09-30T13:46:02Z", + "creator_id": 3, + "namespace": { + "created_at": "2013-09-30T13:46:02Z", + "description": "", + "id": 3, + "name": "Diaspora", + "owner_id": 1, + "path": "diaspora", + "updated_at": "2013-09-30T13:46:02Z" + }, + "archived": false, + "avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png", + "shared_runners_enabled": true, + "forks_count": 0, + "star_count": 0, + "runners_token": "b8547b1dc37721d05889db52fa2f02", + "public_builds": true, + "shared_with_groups": [], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false + }, + { + "id": 6, + "description": null, + "default_branch": "master", + "public": false, + "visibility_level": 0, + "ssh_url_to_repo": "git@example.com:brightbox/puppet.git", + "http_url_to_repo": "http://example.com/brightbox/puppet.git", + "web_url": "http://example.com/brightbox/puppet", + "tag_list": [ + "example", + "puppet" + ], + "owner": { + "id": 4, + "name": "Brightbox", + "created_at": "2013-09-30T13:46:02Z" + }, + "name": "Puppet", + "name_with_namespace": "Brightbox / Puppet", + "path": "puppet", + "path_with_namespace": "brightbox/puppet", + "issues_enabled": true, + "open_issues_count": 1, + "merge_requests_enabled": true, + "builds_enabled": true, + "wiki_enabled": true, + "snippets_enabled": false, + "container_registry_enabled": false, + "created_at": "2013-09-30T13:46:02Z", + "last_activity_at": "2013-09-30T13:46:02Z", + "creator_id": 3, + "namespace": { + "created_at": "2013-09-30T13:46:02Z", + "description": "", + "id": 4, + "name": "Brightbox", + "owner_id": 1, + "path": "brightbox", + "updated_at": "2013-09-30T13:46:02Z" + }, + "permissions": { + "project_access": { + "access_level": 10, + "notification_level": 3 + }, + "group_access": { + "access_level": 50, + "notification_level": 3 + } + }, + "archived": false, + "avatar_url": null, + "shared_runners_enabled": true, + "forks_count": 0, + "star_count": 0, + "runners_token": "b8547b1dc37721d05889db52fa2f02", + "public_builds": true, + "shared_with_groups": [], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false + } +] +``` + +Get a list of projects which the authenticated user can see. + +``` +GET /projects/visible +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `archived` | boolean | no | Limit by archived status | +| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | +| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | +| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Return list of authorized projects matching the search criteria | +| `simple` | boolean | no | Return only the ID, URL, name, and path of each project | ```json [ @@ -159,11 +298,13 @@ GET /projects/owned Parameters: -- `archived` (optional) - if passed, limit by archived status -- `visibility` (optional) - if passed, limit by visibility `public`, `internal`, `private` -- `order_by` (optional) - Return requests ordered by `id`, `name`, `path`, `created_at`, `updated_at` or `last_activity_at` fields. Default is `created_at` -- `sort` (optional) - Return requests sorted in `asc` or `desc` order. Default is `desc` -- `search` (optional) - Return list of authorized projects according to a search criteria +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `archived` | boolean | no | Limit by archived status | +| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | +| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | +| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Return list of authorized projects matching the search criteria | ### List starred projects @@ -175,11 +316,13 @@ GET /projects/starred Parameters: -- `archived` (optional) - if passed, limit by archived status -- `visibility` (optional) - if passed, limit by visibility `public`, `internal`, `private` -- `order_by` (optional) - Return requests ordered by `id`, `name`, `path`, `created_at`, `updated_at` or `last_activity_at` fields. Default is `created_at` -- `sort` (optional) - Return requests sorted in `asc` or `desc` order. Default is `desc` -- `search` (optional) - Return list of authorized projects according to a search criteria +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `archived` | boolean | no | Limit by archived status | +| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | +| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | +| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Return list of authorized projects matching the search criteria | ### List ALL projects @@ -191,11 +334,13 @@ GET /projects/all Parameters: -- `archived` (optional) - if passed, limit by archived status -- `visibility` (optional) - if passed, limit by visibility `public`, `internal`, `private` -- `order_by` (optional) - Return requests ordered by `id`, `name`, `path`, `created_at`, `updated_at` or `last_activity_at` fields. Default is `created_at` -- `sort` (optional) - Return requests sorted in `asc` or `desc` order. Default is `desc` -- `search` (optional) - Return list of authorized projects according to a search criteria +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `archived` | boolean | no | Limit by archived status | +| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | +| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | +| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Return list of authorized projects matching the search criteria | ### Get single project @@ -208,7 +353,9 @@ GET /projects/:id Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or NAMESPACE/PROJECT_NAME of the project | ```json { @@ -280,14 +427,16 @@ Parameters: "group_name": "Gitlab Org", "group_access_level": 10 } - ] + ], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false } ``` ### Get project events Get the events for the specified project. -Sorted from newest to latest +Sorted from newest to oldest ``` GET /projects/:id/events @@ -295,7 +444,9 @@ GET /projects/:id/events Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or NAMESPACE/PROJECT_NAME of the project | ```json [ @@ -433,21 +584,26 @@ POST /projects Parameters: -- `name` (required) - new project name -- `path` (optional) - custom repository name for new project. By default generated based on name -- `namespace_id` (optional) - namespace for the new project (defaults to user) -- `description` (optional) - short project description -- `issues_enabled` (optional) -- `merge_requests_enabled` (optional) -- `builds_enabled` (optional) -- `wiki_enabled` (optional) -- `snippets_enabled` (optional) -- `container_registry_enabled` (optional) -- `shared_runners_enabled` (optional) -- `public` (optional) - if `true` same as setting visibility_level = 20 -- `visibility_level` (optional) -- `import_url` (optional) -- `public_builds` (optional) +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | yes | The name of the new project | +| `path` | string | no | Custom repository name for new project. By default generated based on name | +| `namespace_id` | integer | no | Namespace for the new project (defaults to the current user's namespace) | +| `description` | string | no | Short project description | +| `issues_enabled` | boolean | no | Enable issues for this project | +| `merge_requests_enabled` | boolean | no | Enable merge requests for this project | +| `builds_enabled` | boolean | no | Enable builds for this project | +| `wiki_enabled` | boolean | no | Enable wiki for this project | +| `snippets_enabled` | boolean | no | Enable snippets for this project | +| `container_registry_enabled` | boolean | no | Enable container registry for this project | +| `shared_runners_enabled` | boolean | no | Enable shared runners for this project | +| `public` | boolean | no | If `true`, the same as setting `visibility_level` to 20 | +| `visibility_level` | integer | no | See [project visibility level][#project-visibility-level] | +| `import_url` | string | no | URL to import repository from | +| `public_builds` | boolean | no | If `true`, builds can be viewed by non-project-members | +| `only_allow_merge_if_build_succeeds` | boolean | no | Set whether merge requests can only be merged with successful builds | +| `lfs_enabled` | boolean | no | Enable LFS | +| `request_access_enabled` | boolean | no | Allow users to request member access | ### Create project for user @@ -459,20 +615,27 @@ POST /projects/user/:user_id Parameters: -- `user_id` (required) - user_id of owner -- `name` (required) - new project name -- `description` (optional) - short project description -- `issues_enabled` (optional) -- `merge_requests_enabled` (optional) -- `builds_enabled` (optional) -- `wiki_enabled` (optional) -- `snippets_enabled` (optional) -- `container_registry_enabled` (optional) -- `shared_runners_enabled` (optional) -- `public` (optional) - if `true` same as setting visibility_level = 20 -- `visibility_level` (optional) -- `import_url` (optional) -- `public_builds` (optional) +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `user_id` | integer | yes | The user ID of the project owner | +| `name` | string | yes | The name of the new project | +| `path` | string | no | Custom repository name for new project. By default generated based on name | +| `namespace_id` | integer | no | Namespace for the new project (defaults to the current user's namespace) | +| `description` | string | no | Short project description | +| `issues_enabled` | boolean | no | Enable issues for this project | +| `merge_requests_enabled` | boolean | no | Enable merge requests for this project | +| `builds_enabled` | boolean | no | Enable builds for this project | +| `wiki_enabled` | boolean | no | Enable wiki for this project | +| `snippets_enabled` | boolean | no | Enable snippets for this project | +| `container_registry_enabled` | boolean | no | Enable container registry for this project | +| `shared_runners_enabled` | boolean | no | Enable shared runners for this project | +| `public` | boolean | no | If `true`, the same as setting `visibility_level` to 20 | +| `visibility_level` | integer | no | See [project visibility level][#project-visibility-level] | +| `import_url` | string | no | URL to import repository from | +| `public_builds` | boolean | no | If `true`, builds can be viewed by non-project-members | +| `only_allow_merge_if_build_succeeds` | boolean | no | Set whether merge requests can only be merged with successful builds | +| `lfs_enabled` | boolean | no | Enable LFS | +| `request_access_enabled` | boolean | no | Allow users to request member access | ### Edit project @@ -484,28 +647,33 @@ PUT /projects/:id Parameters: -- `id` (required) - The ID of a project -- `name` (optional) - project name -- `path` (optional) - repository name for project -- `description` (optional) - short project description -- `default_branch` (optional) -- `issues_enabled` (optional) -- `merge_requests_enabled` (optional) -- `builds_enabled` (optional) -- `wiki_enabled` (optional) -- `snippets_enabled` (optional) -- `container_registry_enabled` (optional) -- `shared_runners_enabled` (optional) -- `public` (optional) - if `true` same as setting visibility_level = 20 -- `visibility_level` (optional) -- `public_builds` (optional) +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or NAMESPACE/PROJECT_NAME of the project | +| `name` | string | yes | The name of the project | +| `path` | string | no | Custom repository name for the project. By default generated based on name | +| `description` | string | no | Short project description | +| `issues_enabled` | boolean | no | Enable issues for this project | +| `merge_requests_enabled` | boolean | no | Enable merge requests for this project | +| `builds_enabled` | boolean | no | Enable builds for this project | +| `wiki_enabled` | boolean | no | Enable wiki for this project | +| `snippets_enabled` | boolean | no | Enable snippets for this project | +| `container_registry_enabled` | boolean | no | Enable container registry for this project | +| `shared_runners_enabled` | boolean | no | Enable shared runners for this project | +| `public` | boolean | no | If `true`, the same as setting `visibility_level` to 20 | +| `visibility_level` | integer | no | See [project visibility level][#project-visibility-level] | +| `import_url` | string | no | URL to import repository from | +| `public_builds` | boolean | no | If `true`, builds can be viewed by non-project-members | +| `only_allow_merge_if_build_succeeds` | boolean | no | Set whether merge requests can only be merged with successful builds | +| `lfs_enabled` | boolean | no | Enable LFS | +| `request_access_enabled` | boolean | no | Allow users to request member access | On success, method returns 200 with the updated project. If parameters are invalid, 400 is returned. ### Fork project -Forks a project into the user namespace of the authenticated user. +Forks a project into the user namespace of the authenticated user or the one provided. ``` POST /projects/fork/:id @@ -513,7 +681,10 @@ POST /projects/fork/:id Parameters: -- `id` (required) - The ID of the project to be forked +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or NAMESPACE/PROJECT_NAME of the project | +| `namespace` | integer/string | yes | The ID or path of the namespace that the project will be forked to | ### Star a project @@ -524,12 +695,14 @@ Stars a given project. Returns status code `201` and the project on success and POST /projects/:id/star ``` +Parameters: + | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of the project | +| `id` | integer/string | yes | The ID or NAMESPACE/PROJECT_NAME of the project | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/star" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/star" ``` Example response: @@ -577,7 +750,9 @@ Example response: "forks_count": 0, "star_count": 1, "public_builds": true, - "shared_with_groups": [] + "shared_with_groups": [], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false } ``` @@ -592,10 +767,10 @@ DELETE /projects/:id/star | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of the project | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/star" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/star" ``` Example response: @@ -643,7 +818,9 @@ Example response: "forks_count": 0, "star_count": 0, "public_builds": true, - "shared_with_groups": [] + "shared_with_groups": [], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false } ``` @@ -662,10 +839,10 @@ POST /projects/:id/archive | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of the project | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/archive" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/archive" ``` Example response: @@ -729,7 +906,9 @@ Example response: "star_count": 0, "runners_token": "b8bc4a7a29eb76ea83cf79e4908c2b", "public_builds": true, - "shared_with_groups": [] + "shared_with_groups": [], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false } ``` @@ -748,10 +927,10 @@ POST /projects/:id/unarchive | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of the project | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/unarchive" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/unarchive" ``` Example response: @@ -815,7 +994,9 @@ Example response: "star_count": 0, "runners_token": "b8bc4a7a29eb76ea83cf79e4908c2b", "public_builds": true, - "shared_with_groups": [] + "shared_with_groups": [], + "only_allow_merge_if_build_succeeds": false, + "request_access_enabled": false } ``` @@ -829,7 +1010,9 @@ DELETE /projects/:id Parameters: -- `id` (required) - The ID of a project +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ## Uploads @@ -843,8 +1026,10 @@ POST /projects/:id/uploads Parameters: -- `id` (required) - The ID of the project -- `file` (required) - The file to be uploaded +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `file` | string | yes | The file to be uploaded | ```json { @@ -858,95 +1043,9 @@ Parameters: In Markdown contexts, the link is automatically expanded when the format in `markdown` is used. -## Team members - -### List project team members - -Get a list of a project's team members. - -``` -GET /projects/:id/members -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `query` (optional) - Query string to search for members - -### Get project team member - -Gets a project team member. - -``` -GET /projects/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a user - -```json -{ - "id": 1, - "username": "john_smith", - "email": "john@example.com", - "name": "John Smith", - "state": "active", - "created_at": "2012-05-23T08:00:58Z", - "access_level": 40 -} -``` - -### Add project team member - -Adds a user to a project team. This is an idempotent method and can be called multiple times -with the same parameters. Adding team membership to a user that is already a member does not -affect the existing membership. - -``` -POST /projects/:id/members -``` - -Parameters: +## Project members -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a user to add -- `access_level` (required) - Project access level - -### Edit project team member - -Updates a project team member to a specified access level. - -``` -PUT /projects/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a team member -- `access_level` (required) - Project access level - -### Remove project team member - -Removes a user from a project team. - -``` -DELETE /projects/:id/members/:user_id -``` - -Parameters: - -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `user_id` (required) - The ID of a team member - -This method removes the project member if the user has the proper access rights to do so. -It returns a status code 403 if the member does not have the proper rights to perform this action. -In all other cases this method is idempotent and revoking team membership for a user who is not -currently a team member is considered success. -Please note that the returned JSON currently differs slightly. Thus you should not -rely on the returned JSON structure. +Please consult the [Project Members](members.md) documentation. ### Share project with group @@ -958,9 +1057,12 @@ POST /projects/:id/share Parameters: -- `id` (required) - The ID of a project -- `group_id` (required) - The ID of a group -- `group_access` (required) - Level of permissions for sharing +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `group_id` | integer | yes | The ID of the group to share with | +| `group_access` | integer | yes | The permissions level to grant the group | +| `expires_at` | string | no | Share expiration date in ISO 8601 format: 2016-09-26 | ## Hooks @@ -977,7 +1079,9 @@ GET /projects/:id/hooks Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ### Get project hook @@ -989,8 +1093,10 @@ GET /projects/:id/hooks/:hook_id Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `hook_id` (required) - The ID of a project hook +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `hook_id` | integer | yes | The ID of a project hook | ```json { @@ -1000,7 +1106,11 @@ Parameters: "push_events": true, "issues_events": true, "merge_requests_events": true, + "tag_push_events": true, "note_events": true, + "build_events": true, + "pipeline_events": true, + "wiki_page_events": true, "enable_ssl_verification": true, "created_at": "2012-10-12T17:04:47Z" } @@ -1016,14 +1126,19 @@ POST /projects/:id/hooks Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `url` (required) - The hook URL -- `push_events` - Trigger hook on push events -- `issues_events` - Trigger hook on issues events -- `merge_requests_events` - Trigger hook on merge_requests events -- `tag_push_events` - Trigger hook on push_tag events -- `note_events` - Trigger hook on note events -- `enable_ssl_verification` - Do SSL verification when triggering the hook +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `url` | string | yes | The hook URL | +| `push_events` | boolean | no | Trigger hook on push events | +| `issues_events` | boolean | no | Trigger hook on issues events | +| `merge_requests_events` | boolean | no | Trigger hook on merge requests events | +| `tag_push_events` | boolean | no | Trigger hook on tag push events | +| `note_events` | boolean | no | Trigger hook on note events | +| `build_events` | boolean | no | Trigger hook on build events | +| `pipeline_events` | boolean | no | Trigger hook on pipeline events | +| `wiki_events` | boolean | no | Trigger hook on wiki events | +| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook | ### Edit project hook @@ -1035,15 +1150,20 @@ PUT /projects/:id/hooks/:hook_id Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `hook_id` (required) - The ID of a project hook -- `url` (required) - The hook URL -- `push_events` - Trigger hook on push events -- `issues_events` - Trigger hook on issues events -- `merge_requests_events` - Trigger hook on merge_requests events -- `tag_push_events` - Trigger hook on push_tag events -- `note_events` - Trigger hook on note events -- `enable_ssl_verification` - Do SSL verification when triggering the hook +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `hook_id` | integer | yes | The ID of the project hook | +| `url` | string | yes | The hook URL | +| `push_events` | boolean | no | Trigger hook on push events | +| `issues_events` | boolean | no | Trigger hook on issues events | +| `merge_requests_events` | boolean | no | Trigger hook on merge requests events | +| `tag_push_events` | boolean | no | Trigger hook on tag push events | +| `note_events` | boolean | no | Trigger hook on note events | +| `build_events` | boolean | no | Trigger hook on build events | +| `pipeline_events` | boolean | no | Trigger hook on pipeline events | +| `wiki_events` | boolean | no | Trigger hook on wiki events | +| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook | ### Delete project hook @@ -1056,14 +1176,18 @@ DELETE /projects/:id/hooks/:hook_id Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `hook_id` (required) - The ID of hook to delete +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `hook_id` | integer | yes | The ID of the project hook | Note the JSON response differs if the hook is available or not. If the project hook is available before it is returned in the JSON response or an empty response is returned. ## Branches +For more information please consult the [Branches](branches.md) documentation. + ### List branches Lists all branches of a project. @@ -1074,7 +1198,9 @@ GET /projects/:id/repository/branches Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```json [ @@ -1082,56 +1208,46 @@ Parameters: "name": "async", "commit": { "id": "a2b702edecdf41f07b42653eb1abe30ce98b9fca", - "parents": [ - { - "id": "3f94fc7c85061973edc9906ae170cc269b07ca55" - } + "parent_ids": [ + "3f94fc7c85061973edc9906ae170cc269b07ca55" ], - "tree": "c68537c6534a02cc2b176ca1549f4ffa190b58ee", "message": "give Caolan credit where it's due (up top)", - "author": { - "name": "Jeremy Ashkenas", - "email": "jashkenas@example.com" - }, - "committer": { - "name": "Jeremy Ashkenas", - "email": "jashkenas@example.com" - }, + "author_name": "Jeremy Ashkenas", + "author_email": "jashkenas@example.com", "authored_date": "2010-12-08T21:28:50+00:00", + "committer_name": "Jeremy Ashkenas", + "committer_email": "jashkenas@example.com", "committed_date": "2010-12-08T21:28:50+00:00" }, - "protected": false + "protected": false, + "developers_can_push": false, + "developers_can_merge": false }, { "name": "gh-pages", "commit": { "id": "101c10a60019fe870d21868835f65c25d64968fc", - "parents": [ - { - "id": "9c15d2e26945a665131af5d7b6d30a06ba338aaa" - } + "parent_ids": [ + "9c15d2e26945a665131af5d7b6d30a06ba338aaa" ], - "tree": "fb5cc9d45da3014b17a876ad539976a0fb9b352a", "message": "Underscore.js 1.5.2", - "author": { - "name": "Jeremy Ashkenas", - "email": "jashkenas@example.com" - }, - "committer": { - "name": "Jeremy Ashkenas", - "email": "jashkenas@example.com" - }, + "author_name": "Jeremy Ashkenas", + "author_email": "jashkenas@example.com", "authored_date": "2013-09-07T12:58:21+00:00", + "committer_name": "Jeremy Ashkenas", + "committer_email": "jashkenas@example.com", "committed_date": "2013-09-07T12:58:21+00:00" }, - "protected": false + "protected": false, + "developers_can_push": false, + "developers_can_merge": false } ] ``` -### List single branch +### Single branch -Lists a specific branch of a project. +A specific branch of a project. ``` GET /projects/:id/repository/branches/:branch @@ -1139,8 +1255,12 @@ GET /projects/:id/repository/branches/:branch Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `branch` (required) - The name of the branch. +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `branch` | string | yes | The name of the branch | +| `developers_can_push` | boolean | no | Flag if developers can push to the branch | +| `developers_can_merge` | boolean | no | Flag if developers can merge to the branch | ### Protect single branch @@ -1152,8 +1272,10 @@ PUT /projects/:id/repository/branches/:branch/protect Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `branch` (required) - The name of the branch. +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `branch` | string | yes | The name of the branch | ### Unprotect single branch @@ -1165,8 +1287,10 @@ PUT /projects/:id/repository/branches/:branch/unprotect Parameters: -- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project -- `branch` (required) - The name of the branch. +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `branch` | string | yes | The name of the branch | ## Admin fork relation @@ -1180,8 +1304,10 @@ POST /projects/:id/fork/:forked_from_id Parameters: -- `id` (required) - The ID of the project -- `forked_from_id:` (required) - The ID of the project that was forked from +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | +| `forked_from_id` | ID | yes | The ID of the project that was forked from | ### Delete an existing forked from relationship @@ -1191,7 +1317,9 @@ DELETE /projects/:id/fork Parameter: -- `id` (required) - The ID of the project +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ## Search for projects by name @@ -1203,8 +1331,10 @@ GET /projects/search/:query Parameters: -- `query` (required) - A string contained in the project name -- `per_page` (optional) - number of projects to return per page -- `page` (optional) - the page to retrieve -- `order_by` (optional) - Return requests ordered by `id`, `name`, `created_at` or `last_activity_at` fields -- `sort` (optional) - Return requests sorted in `asc` or `desc` order +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` (required) - A string contained in the project name +| `per_page` (optional) - number of projects to return per page +| `page` (optional) - the page to retrieve +| `order_by` (optional) - Return requests ordered by `id`, `name`, `created_at` or `last_activity_at` fields +| `sort` | string | no | Return requests sorted in `asc` or `desc` order | diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 623063f357b..1bc6a24e914 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -12,6 +12,10 @@ Allows you to receive information about file in repository like name, size, cont GET /projects/:id/repository/files ``` +```bash +curl --request GET --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/models/key.rb&ref=master' +``` + Example response: ```json @@ -39,6 +43,10 @@ Parameters: POST /projects/:id/repository/files ``` +```bash +curl --request POST --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&author_email=author%40example.com&author_name=Firstname%20Lastname&content=some%20content&commit_message=create%20a%20new%20file' +``` + Example response: ```json @@ -53,6 +61,8 @@ Parameters: - `file_path` (required) - Full path to new file. Ex. lib/class.rb - `branch_name` (required) - The name of branch - `encoding` (optional) - 'text' or 'base64'. Text is default. +- `author_email` (optional) - Specify the commit author's email address +- `author_name` (optional) - Specify the commit author's name - `content` (required) - File content - `commit_message` (required) - Commit message @@ -62,6 +72,10 @@ Parameters: PUT /projects/:id/repository/files ``` +```bash +curl --request PUT --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&author_email=author%40example.com&author_name=Firstname%20Lastname&content=some%20other%20content&commit_message=update%20file' +``` + Example response: ```json @@ -76,6 +90,8 @@ Parameters: - `file_path` (required) - Full path to file. Ex. lib/class.rb - `branch_name` (required) - The name of branch - `encoding` (optional) - 'text' or 'base64'. Text is default. +- `author_email` (optional) - Specify the commit author's email address +- `author_name` (optional) - Specify the commit author's name - `content` (required) - New file content - `commit_message` (required) - Commit message @@ -94,6 +110,10 @@ Currently gitlab-shell has a boolean return code, preventing GitLab from specify DELETE /projects/:id/repository/files ``` +```bash +curl --request PUT --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&author_email=author%40example.com&author_name=Firstname%20Lastname&commit_message=delete%20file' +``` + Example response: ```json @@ -107,4 +127,6 @@ Parameters: - `file_path` (required) - Full path to file. Ex. lib/class.rb - `branch_name` (required) - The name of branch +- `author_email` (optional) - Specify the commit author's email address +- `author_name` (optional) - Specify the commit author's name - `commit_message` (required) - Commit message diff --git a/doc/api/runners.md b/doc/api/runners.md index ddfa298f79d..28610762dca 100644 --- a/doc/api/runners.md +++ b/doc/api/runners.md @@ -18,7 +18,7 @@ GET /runners?scope=active | `scope` | string | no | The scope of specific runners to show, one of: `active`, `paused`, `online`; showing all runners if none provided | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners" ``` Example response: @@ -57,7 +57,7 @@ GET /runners/all?scope=online | `scope` | string | no | The scope of runners to show, one of: `specific`, `shared`, `active`, `paused`, `online`; showing all runners if none provided | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/all" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/all" ``` Example response: @@ -108,7 +108,7 @@ GET /runners/:id | `id` | integer | yes | The ID of a runner | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" ``` Example response: @@ -158,7 +158,7 @@ PUT /runners/:id | `tag_list` | array | no | The list of tags for a runner; put array of tags, that should be finally assigned to a runner | ``` -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" -F "description=test-1-20150125-test" -F "tag_list=ruby,mysql,tag1,tag2" +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" --form "description=test-1-20150125-test" --form "tag_list=ruby,mysql,tag1,tag2" ``` Example response: @@ -207,7 +207,7 @@ DELETE /runners/:id | `id` | integer | yes | The ID of a runner | ``` -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" ``` Example response: @@ -237,7 +237,7 @@ GET /projects/:id/runners | `id` | integer | yes | The ID of a project | ``` -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners" +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners" ``` Example response: @@ -275,7 +275,7 @@ POST /projects/:id/runners | `runner_id` | integer | yes | The ID of a runner | ``` -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners" -F "runner_id=9" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners" --form "runner_id=9" ``` Example response: @@ -306,7 +306,7 @@ DELETE /projects/:id/runners/:runner_id | `runner_id` | integer | yes | The ID of a runner | ``` -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners/9" +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners/9" ``` Example response: diff --git a/doc/api/services.md b/doc/api/services.md index f821a614047..579fdc0c8c9 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -355,7 +355,7 @@ PUT /projects/:id/services/gemnasium Parameters: -- `api_key` (**required**) - Your personal API KEY on gemnasium.com +- `api_key` (**required**) - Your personal API KEY on gemnasium.com - `token` (**required**) - The project's slug on gemnasium.com ### Delete Gemnasium service @@ -503,6 +503,7 @@ PUT /projects/:id/services/pivotaltracker Parameters: - `token` (**required**) +- `restrict_to_branch` (optional) - Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches. ### Delete PivotalTracker service @@ -661,4 +662,3 @@ Get JetBrains TeamCity CI service settings for a project. ``` GET /projects/:id/services/teamcity ``` - diff --git a/doc/api/session.md b/doc/api/session.md index 066a055702d..f776424023e 100644 --- a/doc/api/session.md +++ b/doc/api/session.md @@ -2,7 +2,7 @@ ## Deprecation Notice -1. Starting in GitLab 9.0, this feature will be *disabled* for users with two-factor authentication turned on. +1. Starting in GitLab 8.11, this feature has been *disabled* for users with two-factor authentication turned on. 2. These users can access the API using [personal access tokens] instead. --- @@ -21,7 +21,7 @@ POST /session | `password` | string | yes | The password of the user | ```bash -curl -X POST "https://gitlab.example.com/api/v3/session?login=john_smith&password=strongpassw0rd" +curl --request POST "https://gitlab.example.com/api/v3/session?login=john_smith&password=strongpassw0rd" ``` Example response: diff --git a/doc/api/settings.md b/doc/api/settings.md index ea39b32561c..f7ad3b4cc8e 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -13,7 +13,7 @@ GET /application/settings ``` ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings ``` Example response: @@ -41,7 +41,9 @@ Example response: "gravatar_enabled" : true, "sign_in_text" : null, "container_registry_token_expire_delay": 5, - "repository_storage": "default" + "repository_storage": "default", + "koding_enabled": false, + "koding_url": null } ``` @@ -67,15 +69,17 @@ PUT /application/settings | `default_snippet_visibility` | integer | no | What visibility level new snippets receive. Can take `0` _(Private)_, `1` _(Internal)_ and `2` _(Public)_ as a parameter. Default is `0`.| | `domain_whitelist` | array of strings | no | Force people to use only corporate emails for sign-up. Default is null, meaning there is no restriction. | | `domain_blacklist_enabled` | boolean | no | Enable/disable the `domain_blacklist` | -| `domain_blacklist` | array of strings | yes (if `domain_whitelist_enabled` is `true` | People trying to sign-up with emails from this domain will not be allowed to do so. | +| `domain_blacklist` | array of strings | yes (if `domain_blacklist_enabled` is `true`) | People trying to sign-up with emails from this domain will not be allowed to do so. | | `user_oauth_applications` | boolean | no | Allow users to register any application to use GitLab as an OAuth provider | | `after_sign_out_path` | string | no | Where to redirect users after logout | | `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes | | `repository_storage` | string | no | Storage path for new projects. The value should be the name of one of the repository storage paths defined in your gitlab.yml | -| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. +| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. | +| `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. | ```bash -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings?signup_enabled=false&default_project_visibility=1 +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings?signup_enabled=false&default_project_visibility=1 ``` Example response: @@ -103,6 +107,8 @@ Example response: "user_oauth_applications": true, "after_sign_out_path": "", "container_registry_token_expire_delay": 5, - "repository_storage": "default" + "repository_storage": "default", + "koding_enabled": false, + "koding_url": null } ``` diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md index ebd131c94ca..1ae732d40d6 100644 --- a/doc/api/sidekiq_metrics.md +++ b/doc/api/sidekiq_metrics.md @@ -15,7 +15,7 @@ GET /sidekiq/queue_metrics ``` ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/queue_metrics +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/queue_metrics ``` Example response: @@ -40,7 +40,7 @@ GET /sidekiq/process_metrics ``` ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/process_metrics +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/process_metrics ``` Example response: @@ -82,7 +82,7 @@ GET /sidekiq/job_stats ``` ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/job_stats +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/job_stats ``` Example response: @@ -106,7 +106,7 @@ GET /sidekiq/compound_metrics ``` ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/compound_metrics +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/compound_metrics ``` Example response: diff --git a/doc/api/system_hooks.md b/doc/api/system_hooks.md index dc036d7e27f..1802fae14fe 100644 --- a/doc/api/system_hooks.md +++ b/doc/api/system_hooks.md @@ -20,7 +20,7 @@ GET /hooks Example request: ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/hooks +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/hooks ``` Example response: @@ -52,7 +52,7 @@ POST /hooks Example request: ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/hooks?url=https://gitlab.example.com/hook" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/hooks?url=https://gitlab.example.com/hook" ``` Example response: @@ -80,7 +80,7 @@ GET /hooks/:id Example request: ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/hooks/2 +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/hooks/2 ``` Example response: @@ -117,7 +117,7 @@ DELETE /hooks/:id Example request: ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/hooks/2 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/hooks/2 ``` Example response: diff --git a/doc/api/tags.md b/doc/api/tags.md index ac9fac92f4c..54059117456 100644 --- a/doc/api/tags.md +++ b/doc/api/tags.md @@ -56,7 +56,7 @@ Parameters: | `tag_name` | string | yes | The name of the tag | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/tags/v1.0.0 +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/tags/v1.0.0 ``` Example Response: diff --git a/doc/api/templates/gitignores.md b/doc/api/templates/gitignores.md new file mode 100644 index 00000000000..8235be92b12 --- /dev/null +++ b/doc/api/templates/gitignores.md @@ -0,0 +1,579 @@ +# Gitignores + +## List gitignore templates + +Get all gitignore templates. + +``` +GET /templates/gitignores +``` + +```bash +curl https://gitlab.example.com/api/v3/templates/gitignores +``` + +Example response: + +```json +[ + { + "name": "AppEngine" + }, + { + "name": "Laravel" + }, + { + "name": "Elisp" + }, + { + "name": "SketchUp" + }, + { + "name": "Ada" + }, + { + "name": "Ruby" + }, + { + "name": "Kohana" + }, + { + "name": "Nanoc" + }, + { + "name": "Erlang" + }, + { + "name": "OCaml" + }, + { + "name": "Lithium" + }, + { + "name": "Fortran" + }, + { + "name": "Scala" + }, + { + "name": "Node" + }, + { + "name": "Fancy" + }, + { + "name": "Perl" + }, + { + "name": "Zephir" + }, + { + "name": "WordPress" + }, + { + "name": "Symfony" + }, + { + "name": "FuelPHP" + }, + { + "name": "DM" + }, + { + "name": "Sdcc" + }, + { + "name": "Rust" + }, + { + "name": "C" + }, + { + "name": "Umbraco" + }, + { + "name": "Actionscript" + }, + { + "name": "Android" + }, + { + "name": "Grails" + }, + { + "name": "Composer" + }, + { + "name": "ExpressionEngine" + }, + { + "name": "Gcov" + }, + { + "name": "Qt" + }, + { + "name": "Phalcon" + }, + { + "name": "ArchLinuxPackages" + }, + { + "name": "TeX" + }, + { + "name": "SCons" + }, + { + "name": "Lilypond" + }, + { + "name": "CommonLisp" + }, + { + "name": "Rails" + }, + { + "name": "Mercury" + }, + { + "name": "Magento" + }, + { + "name": "ChefCookbook" + }, + { + "name": "GitBook" + }, + { + "name": "C++" + }, + { + "name": "Eagle" + }, + { + "name": "Go" + }, + { + "name": "OpenCart" + }, + { + "name": "Scheme" + }, + { + "name": "Typo3" + }, + { + "name": "SeamGen" + }, + { + "name": "Swift" + }, + { + "name": "Elm" + }, + { + "name": "Unity" + }, + { + "name": "Agda" + }, + { + "name": "CUDA" + }, + { + "name": "VVVV" + }, + { + "name": "Finale" + }, + { + "name": "LemonStand" + }, + { + "name": "Textpattern" + }, + { + "name": "Julia" + }, + { + "name": "Packer" + }, + { + "name": "Scrivener" + }, + { + "name": "Dart" + }, + { + "name": "Plone" + }, + { + "name": "Jekyll" + }, + { + "name": "Xojo" + }, + { + "name": "LabVIEW" + }, + { + "name": "Autotools" + }, + { + "name": "KiCad" + }, + { + "name": "Prestashop" + }, + { + "name": "ROS" + }, + { + "name": "Smalltalk" + }, + { + "name": "GWT" + }, + { + "name": "OracleForms" + }, + { + "name": "SugarCRM" + }, + { + "name": "Nim" + }, + { + "name": "SymphonyCMS" + }, + { + "name": "Maven" + }, + { + "name": "CFWheels" + }, + { + "name": "Python" + }, + { + "name": "ZendFramework" + }, + { + "name": "CakePHP" + }, + { + "name": "Concrete5" + }, + { + "name": "PlayFramework" + }, + { + "name": "Terraform" + }, + { + "name": "Elixir" + }, + { + "name": "CMake" + }, + { + "name": "Joomla" + }, + { + "name": "Coq" + }, + { + "name": "Delphi" + }, + { + "name": "Haskell" + }, + { + "name": "Yii" + }, + { + "name": "Java" + }, + { + "name": "UnrealEngine" + }, + { + "name": "AppceleratorTitanium" + }, + { + "name": "CraftCMS" + }, + { + "name": "ForceDotCom" + }, + { + "name": "ExtJs" + }, + { + "name": "MetaProgrammingSystem" + }, + { + "name": "D" + }, + { + "name": "Objective-C" + }, + { + "name": "RhodesRhomobile" + }, + { + "name": "R" + }, + { + "name": "EPiServer" + }, + { + "name": "Yeoman" + }, + { + "name": "VisualStudio" + }, + { + "name": "Processing" + }, + { + "name": "Leiningen" + }, + { + "name": "Stella" + }, + { + "name": "Opa" + }, + { + "name": "Drupal" + }, + { + "name": "TurboGears2" + }, + { + "name": "Idris" + }, + { + "name": "Jboss" + }, + { + "name": "CodeIgniter" + }, + { + "name": "Qooxdoo" + }, + { + "name": "Waf" + }, + { + "name": "Sass" + }, + { + "name": "Lua" + }, + { + "name": "Clojure" + }, + { + "name": "IGORPro" + }, + { + "name": "Gradle" + }, + { + "name": "Archives" + }, + { + "name": "SynopsysVCS" + }, + { + "name": "Ninja" + }, + { + "name": "Tags" + }, + { + "name": "OSX" + }, + { + "name": "Dreamweaver" + }, + { + "name": "CodeKit" + }, + { + "name": "NotepadPP" + }, + { + "name": "VisualStudioCode" + }, + { + "name": "Mercurial" + }, + { + "name": "BricxCC" + }, + { + "name": "DartEditor" + }, + { + "name": "Eclipse" + }, + { + "name": "Cloud9" + }, + { + "name": "TortoiseGit" + }, + { + "name": "NetBeans" + }, + { + "name": "GPG" + }, + { + "name": "Espresso" + }, + { + "name": "Redcar" + }, + { + "name": "Xcode" + }, + { + "name": "Matlab" + }, + { + "name": "LyX" + }, + { + "name": "SlickEdit" + }, + { + "name": "Dropbox" + }, + { + "name": "CVS" + }, + { + "name": "Calabash" + }, + { + "name": "JDeveloper" + }, + { + "name": "Vagrant" + }, + { + "name": "IPythonNotebook" + }, + { + "name": "TextMate" + }, + { + "name": "Ensime" + }, + { + "name": "WebMethods" + }, + { + "name": "VirtualEnv" + }, + { + "name": "Emacs" + }, + { + "name": "Momentics" + }, + { + "name": "JetBrains" + }, + { + "name": "SublimeText" + }, + { + "name": "Kate" + }, + { + "name": "ModelSim" + }, + { + "name": "Redis" + }, + { + "name": "KDevelop4" + }, + { + "name": "Bazaar" + }, + { + "name": "Linux" + }, + { + "name": "Windows" + }, + { + "name": "XilinxISE" + }, + { + "name": "Lazarus" + }, + { + "name": "EiffelStudio" + }, + { + "name": "Anjuta" + }, + { + "name": "Vim" + }, + { + "name": "Otto" + }, + { + "name": "MicrosoftOffice" + }, + { + "name": "LibreOffice" + }, + { + "name": "SBT" + }, + { + "name": "MonoDevelop" + }, + { + "name": "SVN" + }, + { + "name": "FlexBuilder" + } +] +``` + +## Single gitignore template + +Get a single gitignore template. + +``` +GET /templates/gitignores/:key +``` + +| Attribute | Type | Required | Description | +| ---------- | ------ | -------- | ----------- | +| `key` | string | yes | The key of the gitignore template | + +```bash +curl https://gitlab.example.com/api/v3/templates/gitignores/Ruby +``` + +Example response: + +```json +{ + "name": "Ruby", + "content": "*.gem\n*.rbc\n/.config\n/coverage/\n/InstalledFiles\n/pkg/\n/spec/reports/\n/spec/examples.txt\n/test/tmp/\n/test/version_tmp/\n/tmp/\n\n# Used by dotenv library to load environment variables.\n# .env\n\n## Specific to RubyMotion:\n.dat*\n.repl_history\nbuild/\n*.bridgesupport\nbuild-iPhoneOS/\nbuild-iPhoneSimulator/\n\n## Specific to RubyMotion (use of CocoaPods):\n#\n# We recommend against adding the Pods directory to your .gitignore. However\n# you should judge for yourself, the pros and cons are mentioned at:\n# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control\n#\n# vendor/Pods/\n\n## Documentation cache and generated files:\n/.yardoc/\n/_yardoc/\n/doc/\n/rdoc/\n\n## Environment normalization:\n/.bundle/\n/vendor/bundle\n/lib/bundler/man/\n\n# for a library or gem, you might want to ignore these files since the code is\n# intended to run in multiple environments; otherwise, check them in:\n# Gemfile.lock\n# .ruby-version\n# .ruby-gemset\n\n# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:\n.rvmrc\n" +} +``` diff --git a/doc/api/templates/gitlab_ci_ymls.md b/doc/api/templates/gitlab_ci_ymls.md new file mode 100644 index 00000000000..e120016fbe6 --- /dev/null +++ b/doc/api/templates/gitlab_ci_ymls.md @@ -0,0 +1,120 @@ +# GitLab CI YMLs + +## List GitLab CI YML templates + +Get all GitLab CI YML templates. + +``` +GET /templates/gitlab_ci_ymls +``` + +```bash +curl https://gitlab.example.com/api/v3/templates/gitlab_ci_ymls +``` + +Example response: + +```json +[ + { + "name": "C++" + }, + { + "name": "Docker" + }, + { + "name": "Elixir" + }, + { + "name": "LaTeX" + }, + { + "name": "Grails" + }, + { + "name": "Rust" + }, + { + "name": "Nodejs" + }, + { + "name": "Ruby" + }, + { + "name": "Scala" + }, + { + "name": "Maven" + }, + { + "name": "Harp" + }, + { + "name": "Pelican" + }, + { + "name": "Hyde" + }, + { + "name": "Nanoc" + }, + { + "name": "Octopress" + }, + { + "name": "JBake" + }, + { + "name": "HTML" + }, + { + "name": "Hugo" + }, + { + "name": "Metalsmith" + }, + { + "name": "Hexo" + }, + { + "name": "Lektor" + }, + { + "name": "Doxygen" + }, + { + "name": "Brunch" + }, + { + "name": "Jekyll" + }, + { + "name": "Middleman" + } +] +``` + +## Single GitLab CI YML template + +Get a single GitLab CI YML template. + +``` +GET /templates/gitlab_ci_ymls/:key +``` + +| Attribute | Type | Required | Description | +| ---------- | ------ | -------- | ----------- | +| `key` | string | yes | The key of the GitLab CI YML template | + +```bash +curl https://gitlab.example.com/api/v3/templates/gitlab_ci_ymls/Ruby +``` + +Example response: + +```json +{ + "name": "Ruby", + "content": "# This file is a template, and might need editing before it works on your project.\n# Official language image. Look for the different tagged releases at:\n# https://hub.docker.com/r/library/ruby/tags/\nimage: \"ruby:2.3\"\n\n# Pick zero or more services to be used on all builds.\n# Only needed when using a docker container to run your tests in.\n# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service\nservices:\n - mysql:latest\n - redis:latest\n - postgres:latest\n\nvariables:\n POSTGRES_DB: database_name\n\n# Cache gems in between builds\ncache:\n paths:\n - vendor/ruby\n\n# This is a basic example for a gem or script which doesn't use\n# services such as redis or postgres\nbefore_script:\n - ruby -v # Print out ruby version for debugging\n # Uncomment next line if your rails app needs a JS runtime:\n # - apt-get update -q && apt-get install nodejs -yqq\n - gem install bundler --no-ri --no-rdoc # Bundler is not installed with the image\n - bundle install -j $(nproc) --path vendor # Install dependencies into ./vendor/ruby\n\n# Optional - Delete if not using `rubocop`\nrubocop:\n script:\n - rubocop\n\nrspec:\n script:\n - rspec spec\n\nrails:\n variables:\n DATABASE_URL: \"postgresql://postgres:postgres@postgres:5432/$POSTGRES_DB\"\n script:\n - bundle exec rake db:migrate\n - bundle exec rake db:seed\n - bundle exec rake test\n" +} +``` diff --git a/doc/api/licenses.md b/doc/api/templates/licenses.md index 855b0eab56f..ae7218cf1bd 100644 --- a/doc/api/licenses.md +++ b/doc/api/templates/licenses.md @@ -5,7 +5,7 @@ Get all license templates. ``` -GET /licenses +GET /templates/licenses ``` | Attribute | Type | Required | Description | @@ -13,7 +13,7 @@ GET /licenses | `popular` | boolean | no | If passed, returns only popular licenses | ```bash -curl https://gitlab.example.com/api/v3/licenses?popular=1 +curl https://gitlab.example.com/api/v3/templates/licenses?popular=1 ``` Example response: @@ -102,7 +102,7 @@ Get a single license template. You can pass parameters to replace the license placeholder. ``` -GET /licenses/:key +GET /templates/licenses/:key ``` | Attribute | Type | Required | Description | @@ -116,7 +116,7 @@ If you omit the `fullname` parameter but authenticate your request, the name of the authenticated user will be used to replace the copyright holder placeholder. ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/licenses/mit?project=My+Cool+Project +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/templates/licenses/mit?project=My+Cool+Project ``` Example response: diff --git a/doc/api/todos.md b/doc/api/todos.md index 937c71de386..0cd644dfd2f 100644 --- a/doc/api/todos.md +++ b/doc/api/todos.md @@ -1,6 +1,6 @@ # Todos -**Note:** This feature was [introduced][ce-3188] in GitLab 8.10 +> [Introduced][ce-3188] in GitLab 8.10. ## Get a list of todos @@ -22,7 +22,7 @@ Parameters: | `type` | string | no | The type of a todo. Can be either `Issue` or `MergeRequest` | ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/todos +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/todos ``` Example Response: @@ -194,7 +194,7 @@ Parameters: | `id` | integer | yes | The ID of a todo | ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/todos/130 +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/todos/130 ``` Example Response: @@ -284,7 +284,7 @@ DELETE /todos ``` ```bash -curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/todos +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/todos ``` Example Response: diff --git a/doc/api/users.md b/doc/api/users.md index 7e848586dbd..a52b2d51d78 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -57,6 +57,7 @@ GET /users "linkedin": "", "twitter": "", "website_url": "", + "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", "theme_id": 1, @@ -89,6 +90,7 @@ GET /users "linkedin": "", "twitter": "", "website_url": "", + "organization": "", "last_sign_in_at": null, "confirmed_at": "2012-05-30T16:53:06.148Z", "theme_id": 1, @@ -147,7 +149,8 @@ Parameters: "skype": "", "linkedin": "", "twitter": "", - "website_url": "" + "website_url": "", + "organization": "" } ``` @@ -178,6 +181,7 @@ Parameters: "linkedin": "", "twitter": "", "website_url": "", + "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", "theme_id": 1, @@ -214,6 +218,7 @@ Parameters: - `linkedin` (optional) - LinkedIn - `twitter` (optional) - Twitter account - `website_url` (optional) - Website URL +- `organization` (optional) - Organization name - `projects_limit` (optional) - Number of projects user can create - `extern_uid` (optional) - External UID - `provider` (optional) - External provider name @@ -242,6 +247,7 @@ Parameters: - `linkedin` - LinkedIn - `twitter` - Twitter account - `website_url` - Website URL +- `organization` - Organization name - `projects_limit` - Limit projects each user can create - `extern_uid` - External UID - `provider` - External provider name @@ -296,6 +302,7 @@ GET /user "linkedin": "", "twitter": "", "website_url": "", + "organization": "", "last_sign_in_at": "2012-06-01T11:41:01Z", "confirmed_at": "2012-05-23T09:05:22Z", "theme_id": 1, @@ -310,8 +317,7 @@ GET /user "can_create_group": true, "can_create_project": true, "two_factor_enabled": true, - "external": false, - "private_token": "dd34asd13as" + "external": false } ``` @@ -621,3 +627,149 @@ Parameters: Will return `200 OK` on success, `404 User Not Found` is user cannot be found or `403 Forbidden` when trying to unblock a user blocked by LDAP synchronization. + +### Get user contribution events + +Get the contribution events for the specified user, sorted from newest to oldest. + +``` +GET /users/:id/events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the user | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/user/:id/events +``` + +Example response: + +```json +[ + { + "title": null, + "project_id": 15, + "action_name": "closed", + "target_id": 830, + "target_type": "Issue", + "author_id": 1, + "data": null, + "target_title": "Public project search field", + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/u/root" + }, + "author_username": "root" + }, + { + "title": null, + "project_id": 15, + "action_name": "opened", + "target_id": null, + "target_type": null, + "author_id": 1, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/u/root" + }, + "author_username": "john", + "data": { + "before": "50d4420237a9de7be1304607147aec22e4a14af7", + "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "ref": "refs/heads/master", + "user_id": 1, + "user_name": "Dmitriy Zaporozhets", + "repository": { + "name": "gitlabhq", + "url": "git@dev.gitlab.org:gitlab/gitlabhq.git", + "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.", + "homepage": "https://dev.gitlab.org/gitlab/gitlabhq" + }, + "commits": [ + { + "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "message": "Add simple search to projects in public area", + "timestamp": "2013-05-13T18:18:08+00:00", + "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "author": { + "name": "Dmitriy Zaporozhets", + "email": "dmitriy.zaporozhets@gmail.com" + } + } + ], + "total_commits_count": 1 + }, + "target_title": null + }, + { + "title": null, + "project_id": 15, + "action_name": "closed", + "target_id": 840, + "target_type": "Issue", + "author_id": 1, + "data": null, + "target_title": "Finish & merge Code search PR", + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/u/root" + }, + "author_username": "root" + }, + { + "title": null, + "project_id": 15, + "action_name": "commented on", + "target_id": 1312, + "target_type": "Note", + "author_id": 1, + "data": null, + "target_title": null, + "created_at": "2015-12-04T10:33:58.089Z", + "note": { + "id": 1312, + "body": "What an awesome day!", + "attachment": null, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/u/root" + }, + "created_at": "2015-12-04T10:33:56.698Z", + "system": false, + "upvote": false, + "downvote": false, + "noteable_id": 377, + "noteable_type": "Issue" + }, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/u/root" + }, + "author_username": "root" + } +] +``` diff --git a/doc/api/version.md b/doc/api/version.md new file mode 100644 index 00000000000..287d17cf97f --- /dev/null +++ b/doc/api/version.md @@ -0,0 +1,23 @@ +# Version API + +>**Note:** This feature was introduced in GitLab 8.13 + +Retrieve version information for this GitLab instance. Responds `200 OK` for +authenticated users. + +``` +GET /version +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/version +``` + +Example response: + +```json +{ + "version": "8.13.0-pre", + "revision": "4e963fe" +} +``` diff --git a/doc/ci/README.md b/doc/ci/README.md index 0833027f91d..341bc85a16a 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -14,7 +14,9 @@ - [Use variables in your `.gitlab-ci.yml`](variables/README.md) - [Use SSH keys in your build environment](ssh_keys/README.md) - [Trigger builds through the API](triggers/README.md) -- [Build artifacts](build_artifacts/README.md) +- [Build artifacts](../user/project/builds/artifacts.md) - [User permissions](../user/permissions.md#gitlab-ci) +- [Build permissions](../user/permissions.md#build-permissions) - [API](../api/ci/README.md) - [CI services (linked docker containers)](services/README.md) +- [**New CI build permissions model**](../user/project/new_ci_build_permissions_model.md) Read about what changed in GitLab 8.12 and how that affects your builds. There's a new way to access your Git submodules and LFS objects in builds. diff --git a/doc/ci/build_artifacts/README.md b/doc/ci/build_artifacts/README.md index 9553bb11e9d..05605f10fb4 100644 --- a/doc/ci/build_artifacts/README.md +++ b/doc/ci/build_artifacts/README.md @@ -1,175 +1,4 @@ -# Introduction to build artifacts +This document was moved to: -Artifacts is a list of files and directories which are attached to a build -after it completes successfully. This feature is enabled by default in all GitLab installations. - -_If you are searching for ways to use artifacts, jump to -[Defining artifacts in `.gitlab-ci.yml`](#defining-artifacts-in-gitlab-ciyml)._ - -Since GitLab 8.2 and [GitLab Runner] 0.7.0, build artifacts that are created by -GitLab Runner are uploaded to GitLab and are downloadable as a single archive -(`tar.gz`) using the GitLab UI. - -Starting from GitLab 8.4 and GitLab Runner 1.0, the artifacts archive format -changed to `ZIP`, and it is now possible to browse its contents, with the added -ability of downloading the files separately. - -**Note:** -The artifacts browser will be available only for new artifacts that are sent -to GitLab using GitLab Runner version 1.0 and up. It will not be possible to -browse old artifacts already uploaded to GitLab. - -## Disabling build artifacts - -To disable artifacts site-wide, follow the steps below. - ---- - -**In Omnibus installations:** - -1. Edit `/etc/gitlab/gitlab.rb` and add the following line: - - ```ruby - gitlab_rails['artifacts_enabled'] = false - ``` - -1. Save the file and [reconfigure GitLab][] for the changes to take effect. - ---- - -**In installations from source:** - -1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: - - ```yaml - artifacts: - enabled: false - ``` - -1. Save the file and [restart GitLab][] for the changes to take effect. - -## Defining artifacts in `.gitlab-ci.yml` - -A simple example of using the artifacts definition in `.gitlab-ci.yml` would be -the following: - -```yaml -pdf: - script: xelatex mycv.tex - artifacts: - paths: - - mycv.pdf -``` - -A job named `pdf` calls the `xelatex` command in order to build a pdf file from -the latex source file `mycv.tex`. We then define the `artifacts` paths which in -turn are defined with the `paths` keyword. All paths to files and directories -are relative to the repository that was cloned during the build. - -For more examples on artifacts, follow the -[separate artifacts yaml documentation](../yaml/README.md#artifacts). - -## Storing build artifacts - -After a successful build, GitLab Runner uploads an archive containing the build -artifacts to GitLab. - -To change the location where the artifacts are stored, follow the steps below. - ---- - -**In Omnibus installations:** - -_The artifacts are stored by default in -`/var/opt/gitlab/gitlab-rails/shared/artifacts`._ - -1. To change the storage path for example to `/mnt/storage/artifacts`, edit - `/etc/gitlab/gitlab.rb` and add the following line: - - ```ruby - gitlab_rails['artifacts_path'] = "/mnt/storage/artifacts" - ``` - -1. Save the file and [reconfigure GitLab][] for the changes to take effect. - ---- - -**In installations from source:** - -_The artifacts are stored by default in -`/home/git/gitlab/shared/artifacts`._ - -1. To change the storage path for example to `/mnt/storage/artifacts`, edit - `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: - - ```yaml - artifacts: - enabled: true - path: /mnt/storage/artifacts - ``` - -1. Save the file and [restart GitLab][] for the changes to take effect. - -## Browsing build artifacts - -When GitLab receives an artifacts archive, an archive metadata file is also -generated. This metadata file describes all the entries that are located in the -artifacts archive itself. The metadata file is in a binary format, with -additional GZIP compression. - -GitLab does not extract the artifacts archive in order to save space, memory -and disk I/O. It instead inspects the metadata file which contains all the -relevant information. This is especially important when there is a lot of -artifacts, or an archive is a very large file. - ---- - -After a successful build, if you visit the build's specific page, you can see -that there are two buttons. - -One is for downloading the artifacts archive and the other for browsing its -contents. - - - ---- - -The archive browser shows the name and the actual file size of each file in the -archive. If your artifacts contained directories, then you are also able to -browse inside them. - -Below you can see an image of three different file formats, as well as two -directories. - - - ---- - -## Downloading build artifacts - -If you need to download the whole archive, there are buttons in various places -inside GitLab that make that possible. - -1. While on the builds page, you can see the download icon for each build's - artifacts archive in the right corner - -1. While inside a specific build, you are presented with a download button - along with the one that browses the archive - -1. And finally, when browsing an archive you can see the download button at - the top right corner - ---- - -Note that GitLab does not extract the entire artifacts archive to send just a -single file to the user. - -When clicking on a specific file, [GitLab Workhorse] extracts it from the -archive and the download begins. - -This implementation saves space, memory and disk I/O. - -[gitlab runner]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner "GitLab Runner repository" -[reconfigure gitlab]: ../../administration/restart_gitlab.md "How to restart GitLab documentation" -[restart gitlab]: ../../administration/restart_gitlab.md "How to restart GitLab documentation" -[gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository" +- [user/project/builds/artifacts.md](../../user/project/builds/artifacts.md) - user guide +- [administration/build_artifacts.md](../../administration/build_artifacts.md) - administrator guide diff --git a/doc/ci/build_artifacts/img/build_artifacts_browser.png b/doc/ci/build_artifacts/img/build_artifacts_browser.png Binary files differdeleted file mode 100644 index 59cf2b8746b..00000000000 --- a/doc/ci/build_artifacts/img/build_artifacts_browser.png +++ /dev/null diff --git a/doc/ci/build_artifacts/img/build_artifacts_browser_button.png b/doc/ci/build_artifacts/img/build_artifacts_browser_button.png Binary files differdeleted file mode 100644 index 7801c2e6fa6..00000000000 --- a/doc/ci/build_artifacts/img/build_artifacts_browser_button.png +++ /dev/null diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index a849905ac6b..520c8b36a95 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -221,7 +221,7 @@ time. *Note: The following commands are run without root privileges. You should be able to run docker with your regular user account.* -First start with creating a file named `build script`: +First start with creating a file named `build_script`: ```bash cat <<EOF > build_script diff --git a/doc/ci/environments.md b/doc/ci/environments.md index d85b8a34ced..e070302fb82 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -14,6 +14,19 @@ Defining environments in a project's `.gitlab-ci.yml` lets developers track Deployments are created when [jobs] deploy versions of code to [environments]. +### Checkout deployments locally + +Since 8.13, a reference in the git repository is saved for each deployment. So +knowing what the state is of your current environments is only a `git fetch` +away. + +In your git config, append the `[remote "<your-remote>"]` block with an extra +fetch line: + +``` +fetch = +refs/environments/*:refs/remotes/origin/environments/* +``` + ## Defining environments You can create and delete environments manually in the web interface, but we diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index c134106bfd0..ffc310ec8c7 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -1,17 +1,21 @@ # CI Examples +A collection of `.gitlab-ci.yml` files is maintained at the [GitLab CI Yml project][gitlab-ci-templates]. +If your favorite programming language or framework are missing we would love your help by sending a merge request +with a `.gitlab-ci.yml`. + +Apart from those, here is an collection of tutorials and guides on setting up your CI pipeline: + - [Testing a PHP application](php.md) - [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md) - [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md) - [Test a Clojure application](test-clojure-application.md) - [Test a Scala application](test-scala-application.md) +- [Test a Phoenix application](test-phoenix-application.md) - [Using `dpl` as deployment tool](deployment/README.md) -- Help your favorite programming language and GitLab by sending a merge request - with a guide for that language. - -## Outside the documentation - +- [Example project that shows how to use Review Apps](https://gitlab.com/gitlab-examples/review-apps-nginx/) - [Blog post about using GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) -- [Repo's with examples for various languages](https://gitlab.com/groups/gitlab-examples) +- [Repositories with examples for various languages](https://gitlab.com/groups/gitlab-examples) - [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) -- [A collection of useful .gitlab-ci.yml templates](https://gitlab.com/gitlab-org/gitlab-ci-yml) + +[gitlab-ci-templates]: https://gitlab.com/gitlab-org/gitlab-ci-yml diff --git a/doc/ci/examples/php.md b/doc/ci/examples/php.md index bfafcc44d66..175e9d79904 100644 --- a/doc/ci/examples/php.md +++ b/doc/ci/examples/php.md @@ -49,7 +49,7 @@ apt-get update -yqq apt-get install git -yqq # Install phpunit, the tool that we will use for testing -curl -Lo /usr/local/bin/phpunit https://phar.phpunit.de/phpunit.phar +curl --location --output /usr/local/bin/phpunit https://phar.phpunit.de/phpunit.phar chmod +x /usr/local/bin/phpunit # Install mysql driver @@ -235,7 +235,7 @@ cache: before_script: # Install composer dependencies -- curl -sS https://getcomposer.org/installer | php +- curl --silent --show-error https://getcomposer.org/installer | php - php composer.phar install ... diff --git a/doc/ci/examples/test-phoenix-application.md b/doc/ci/examples/test-phoenix-application.md new file mode 100644 index 00000000000..150698ca04b --- /dev/null +++ b/doc/ci/examples/test-phoenix-application.md @@ -0,0 +1,56 @@ +## Test a Phoenix application + +This example demonstrates the integration of Gitlab CI with Phoenix, Elixir and +Postgres. + +### Add `.gitlab-ci.yml` file to project + +The following `.gitlab-ci.yml` should be added in the root of your +repository to trigger CI: + +```yaml +image: elixir:1.3 + +services: + - postgres:9.6 + +variables: + MIX_ENV: "test" + +before_script: + # Setup phoenix dependencies + - apt-get update + - apt-get install -y postgresql-client + - mix local.hex --force + - mix deps.get --only test + - mix ecto.reset + +test: + script: + - mix test +``` + +The variables will set the Mix environment to "test". The +`before_script` will install `psql`, some Phoenix dependencies, and will also +run your migrations. + +Finally, the test `script` will run your tests. + +### Update the Config Settings + +In `config/test.exs`, update the database hostname: + +```elixir +config :my_app, MyApp.Repo, + hostname: if(System.get_env("CI"), do: "postgres", else: "localhost"), +``` + +### Add the Migrations Folder + +If you do not have any migrations yet, you will need to create an empty +`.gitkeep` file in `priv/repo/migrations`. + +### Sources + +- https://medium.com/@nahtnam/using-phoenix-on-gitlab-ci-5a51eec81142 +- https://davejlong.com/ci-with-phoenix-and-gitlab/ diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index 48a9f994759..729c1dc8c0d 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -5,7 +5,7 @@ Introduced in GitLab 8.8. ## Pipelines -A pipeline is a group of [builds] that get executed in [stages] (batches). All +A pipeline is a group of [builds] that get executed in [stages] \(batches). All of the builds in a stage are executed in parallel (if there are enough concurrent [runners]), and if they all succeed, the pipeline moves on to the next stage. If one of the builds fails, the next stage is not (usually) @@ -31,6 +31,45 @@ project. ## Seeing build status Clicking on a pipeline will show the builds that were run for that pipeline. +Clicking on an individual build will show you its build trace, and allow you to +cancel the build, retry it, or erase the build trace. + +## Badges + +There are build status and test coverage report badges available. + +Go to pipeline settings to see available badges and code you can use to embed +badges in the `README.md` or your website. + +### Build status badge + +You can access a build status badge image using following link: + +``` +http://example.gitlab.com/namespace/project/badges/branch/build.svg +``` + +### Test coverage report badge + +GitLab makes it possible to define the regular expression for coverage report, +that each build log will be matched against. This means that each build in the +pipeline can have the test coverage percentage value defined. + +You can access test coverage badge using following link: + +``` +http://example.gitlab.com/namespace/project/badges/branch/coverage.svg +``` + +If you would like to get the coverage report from the specific job, you can add +a `job=coverage_job_name` parameter to the URL. For example, it is possible to +use following Markdown code to embed the est coverage report into `README.md`: + +```markdown + +``` + +The latest successful pipeline will be used to read the test coverage value. [builds]: #builds [jobs]: yaml/README.md#jobs diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 6a3c416d995..c40cdd55ea5 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -105,7 +105,8 @@ What is important is that each job is run independently from each other. If you want to check whether your `.gitlab-ci.yml` file is valid, there is a Lint tool under the page `/ci/lint` of your GitLab instance. You can also find -the link under **Settings > CI settings** in your project. +a "CI Lint" button to go to this page under **Pipelines > Pipelines** and +**Pipelines > Builds** in your project. For more information and a complete `.gitlab-ci.yml` syntax, please read [the documentation on .gitlab-ci.yml](../yaml/README.md). @@ -218,21 +219,13 @@ project's settings. For more information read the [Builds emails service documentation](../../project_services/builds_emails.md). -## Builds badge - -You can access a builds badge image using following link: - -``` -http://example.gitlab.com/namespace/project/badges/branch/build.svg -``` - -Awesome! You started using CI in GitLab! - ## Examples Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. +Awesome! You started using CI in GitLab! + [runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#install-gitlab-runner [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ [examples]: ../examples/README.md diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md index 7c0fb225dac..b858029d25e 100644 --- a/doc/ci/ssh_keys/README.md +++ b/doc/ci/ssh_keys/README.md @@ -30,7 +30,8 @@ This is the universal solution which works with any type of executor ## SSH keys when using the Docker executor You will first need to create an SSH key pair. For more information, follow the -instructions to [generate an SSH key](../../ssh/README.md). +instructions to [generate an SSH key](../../ssh/README.md). Do not add a comment +to the SSH key, or the `before_script` will prompt for a passphrase. Then, create a new **Secret Variable** in your project settings on GitLab following **Settings > Variables**. As **Key** add the name `SSH_PRIVATE_KEY` diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md index 5c316510d0e..84048f1d25f 100644 --- a/doc/ci/triggers/README.md +++ b/doc/ci/triggers/README.md @@ -1,6 +1,10 @@ # Triggering Builds through the API -_**Note:** This feature was [introduced][ci-229] in GitLab CE 7.14_ +> [Introduced][ci-229] in GitLab CE 7.14. + +> **Note**: +GitLab 8.12 has a completely redesigned build permissions system. +Read all about the [new model and its implications](../../user/project/new_ci_build_permissions_model.md#build-triggers). Triggers can be used to force a rebuild of a specific branch, tag or commit, with an API call. @@ -77,9 +81,9 @@ See the [Examples](#examples) section below for more details. Using cURL you can trigger a rebuild with minimal effort, for example: ```bash -curl -X POST \ - -F token=TOKEN \ - -F ref=master \ +curl --request POST \ + --form token=TOKEN \ + --form ref=master \ https://gitlab.example.com/api/v3/projects/9/trigger/builds ``` @@ -88,7 +92,7 @@ In this case, the project with ID `9` will get rebuilt on `master` branch. Alternatively, you can pass the `token` and `ref` arguments in the query string: ```bash -curl -X POST \ +curl --request POST \ "https://gitlab.example.com/api/v3/projects/9/trigger/builds?token=TOKEN&ref=master" ``` @@ -103,7 +107,7 @@ need to add in project's A `.gitlab-ci.yml`: build_docs: stage: deploy script: - - "curl -X POST -F token=TOKEN -F ref=master https://gitlab.example.com/api/v3/projects/9/trigger/builds" + - "curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v3/projects/9/trigger/builds" only: - tags ``` @@ -158,10 +162,10 @@ You can then trigger a rebuild while you pass the `UPLOAD_TO_S3` variable and the script of the `upload_package` job will run: ```bash -curl -X POST \ - -F token=TOKEN \ - -F ref=master \ - -F "variables[UPLOAD_TO_S3]=true" \ +curl --request POST \ + --form token=TOKEN \ + --form ref=master \ + --form "variables[UPLOAD_TO_S3]=true" \ https://gitlab.example.com/api/v3/projects/9/trigger/builds ``` @@ -172,7 +176,7 @@ in conjunction with cron. The example below triggers a build on the `master` branch of project with ID `9` every night at `00:30`: ```bash -30 0 * * * curl -X POST -F token=TOKEN -F ref=master https://gitlab.example.com/api/v3/projects/9/trigger/builds +30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v3/projects/9/trigger/builds ``` [ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229 diff --git a/doc/ci/triggers/img/builds_page.png b/doc/ci/triggers/img/builds_page.png Binary files differindex 2dee8ee6107..c2cf4b1852c 100644 --- a/doc/ci/triggers/img/builds_page.png +++ b/doc/ci/triggers/img/builds_page.png diff --git a/doc/ci/triggers/img/trigger_single_build.png b/doc/ci/triggers/img/trigger_single_build.png Binary files differindex baf3fc183d8..fa86f0fee3d 100644 --- a/doc/ci/triggers/img/trigger_single_build.png +++ b/doc/ci/triggers/img/trigger_single_build.png diff --git a/doc/ci/triggers/img/trigger_variables.png b/doc/ci/triggers/img/trigger_variables.png Binary files differindex 908355c33a5..b2fcc65d304 100644 --- a/doc/ci/triggers/img/trigger_variables.png +++ b/doc/ci/triggers/img/trigger_variables.png diff --git a/doc/ci/triggers/img/triggers_page.png b/doc/ci/triggers/img/triggers_page.png Binary files differindex 69cec5cdebf..438f285ae2d 100644 --- a/doc/ci/triggers/img/triggers_page.png +++ b/doc/ci/triggers/img/triggers_page.png diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 4a7c21f811d..a4c3a731a20 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -34,6 +34,7 @@ The `API_TOKEN` will take the Secure Variable value: `SECURE`. | **CI_BUILD_REF_NAME** | all | all | The branch or tag name for which project is built | | **CI_BUILD_REPO** | all | all | The URL to clone the Git repository | | **CI_BUILD_TRIGGERED** | all | 0.5 | The flag to indicate that build was [triggered] | +| **CI_BUILD_MANUAL** | 8.12 | all | The flag to indicate that build was manually started | | **CI_BUILD_TOKEN** | all | 1.2 | Token used for authenticating with the GitLab Container Registry | | **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | @@ -43,10 +44,13 @@ The `API_TOKEN` will take the Secure Variable value: `SECURE`. | **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | | **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the build is run | | **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | -| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returnes the address of the registry tied to the specific project | +| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | | **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used | | **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab | | **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags | +| **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | +| **GITLAB_USER_ID** | 8.12 | all | The id of the user who started the build | +| **GITLAB_USER_EMAIL** | 8.12 | all | The email of the user who started the build | **Some of the variables are only available when using runner with at least defined version.** @@ -60,6 +64,7 @@ export CI_BUILD_REPO="https://gitab-ci-token:abcde-1234ABCD5678ef@gitlab.com/git export CI_BUILD_TAG="1.0.0" export CI_BUILD_NAME="spec:other" export CI_BUILD_STAGE="test" +export CI_BUILD_MANUAL="true" export CI_BUILD_TRIGGERED="true" export CI_BUILD_TOKEN="abcde-1234ABCD5678ef" export CI_PIPELINE_ID="1000" @@ -76,8 +81,10 @@ export CI_RUNNER_DESCRIPTION="my runner" export CI_RUNNER_TAGS="docker, linux" export CI_SERVER="yes" export CI_SERVER_NAME="GitLab" -export CI_SERVER_REVISION="8.9.0" -export CI_SERVER_VERSION="70606bf" +export CI_SERVER_REVISION="70606bf" +export CI_SERVER_VERSION="8.9.0" +export GITLAB_USER_ID="42" +export GITLAB_USER_EMAIL="alexzander@sporer.com" ``` ### YAML-defined variables @@ -99,6 +106,39 @@ Variables can be defined at a global level, but also at a job level. More information about Docker integration can be found in [Using Docker Images](../docker/using_docker_images.md). +#### Debug tracing + +> **WARNING:** Enabling debug tracing can have severe security implications. The + output **will** contain the content of all your secure variables and any other + secrets! The output **will** be uploaded to the GitLab server and made visible + in build traces! + +By default, GitLab Runner hides most of the details of what it is doing when +processing a job. This behaviour keeps build traces short, and prevents secrets +from being leaked into the trace unless your script writes them to the screen. + +If a job isn't working as expected, this can make the problem difficult to +investigate; in these cases, you can enable debug tracing in `.gitlab-ci.yml`. +Available on GitLab Runner v1.7+, this feature enables the shell's execution +trace, resulting in a verbose build trace listing all commands that were run, +variables that were set, etc. + +Before enabling this, you should ensure builds are visible to +[team members only](../../../user/permissions.md#project-features). You should +also [erase](../pipelines.md#seeing-build-traces) all generated build traces +before making them visible again. + +To enable debug traces, set the `CI_DEBUG_TRACE` variable to `true`: + +```yaml +job1: + variables: + CI_DEBUG_TRACE: "true" +``` + +The [example project](https://gitlab.com/gitlab-examples/ci-debug-trace) +demonstrates a working configuration, including build trace examples. + ### User-defined variables (Secure Variables) **This feature requires GitLab Runner 0.4.0 or higher** diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 01d71088543..59399861a97 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -6,50 +6,6 @@ GitLab Runner to manage your project's builds. If you want a quick introduction to GitLab CI, follow our [quick start guide](../quick_start/README.md). ---- - -<!-- START doctoc generated TOC please keep comment here to allow auto update --> -<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> -**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* - -- [.gitlab-ci.yml](#gitlab-ci-yml) - - [image and services](#image-and-services) - - [before_script](#before_script) - - [after_script](#after_script) - - [stages](#stages) - - [types](#types) - - [variables](#variables) - - [cache](#cache) - - [cache:key](#cache-key) -- [Jobs](#jobs) - - [script](#script) - - [stage](#stage) - - [only and except](#only-and-except) - - [job variables](#job-variables) - - [tags](#tags) - - [allow_failure](#allow_failure) - - [when](#when) - - [Manual actions](#manual-actions) - - [environment](#environment) - - [artifacts](#artifacts) - - [artifacts:name](#artifacts-name) - - [artifacts:when](#artifacts-when) - - [artifacts:expire_in](#artifacts-expire_in) - - [dependencies](#dependencies) - - [before_script and after_script](#before_script-and-after_script) -- [Git Strategy](#git-strategy) -- [Shallow cloning](#shallow-cloning) -- [Hidden jobs](#hidden-jobs) -- [Special YAML features](#special-yaml-features) - - [Anchors](#anchors) -- [Validate the .gitlab-ci.yml](#validate-the-gitlab-ci-yml) -- [Skipping builds](#skipping-builds) -- [Examples](#examples) - -<!-- END doctoc generated TOC please keep comment here to allow auto update --> - ---- - ## .gitlab-ci.yml From version 7.12, GitLab CI uses a [YAML](https://en.wikipedia.org/wiki/YAML) @@ -134,8 +90,7 @@ builds, including deploy builds. This can be an array or a multi-line string. ### after_script ->**Note:** -Introduced in GitLab 8.7 and requires Gitlab Runner v1.2 +> Introduced in GitLab 8.7 and requires Gitlab Runner v1.2 `after_script` is used to define the command that will be run after for all builds. This has to be an array or a multi-line string. @@ -179,11 +134,10 @@ Alias for [stages](#stages). ### variables ->**Note:** -Introduced in GitLab Runner v0.5.0. +> Introduced in GitLab Runner v0.5.0. GitLab CI allows you to add variables to `.gitlab-ci.yml` that are set in the -build environment. The variables are stored in the git repository and are meant +build environment. The variables are stored in the Git repository and are meant to store non-sensitive project configuration, for example: ```yaml @@ -198,10 +152,11 @@ thus allowing to fine tune them. Variables can be also defined on [job level](#job-variables). +[Learn more about variables.](../variables/README.md) + ### cache ->**Note:** -Introduced in GitLab Runner v0.7.0. +> Introduced in GitLab Runner v0.7.0. `cache` is used to specify a list of files and directories which should be cached between builds. @@ -262,8 +217,7 @@ will be always present. For implementation details, please check GitLab Runner. #### cache:key ->**Note:** -Introduced in GitLab Runner v1.0.0. +> Introduced in GitLab Runner v1.0.0. The `key` directive allows you to define the affinity of caching between jobs, allowing to have a single cache for all jobs, @@ -353,7 +307,7 @@ job_name: | except | no | Defines a list of git refs for which build is not created | | tags | no | Defines a list of tags which are used to select Runner | | allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status | -| when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` | +| when | no | Define when to run build. Can be `on_success`, `on_failure`, `always` or `manual` | | dependencies | no | Define other builds that a build depends on so that you can pass artifacts between them| | artifacts | no | Define list of build artifacts | | cache | no | Define list of files that should be cached between subsequent runs | @@ -573,8 +527,7 @@ The above script will: #### Manual actions ->**Note:** -Introduced in GitLab 8.10. +> Introduced in GitLab 8.10. Manual actions are a special type of job that are not executed automatically; they need to be explicitly started by a user. Manual actions can be started @@ -585,17 +538,16 @@ An example usage of manual actions is deployment to production. ### environment ->**Note:** -Introduced in GitLab 8.9. +> Introduced in GitLab 8.9. -`environment` is used to define that a job deploys to a specific environment. +`environment` is used to define that a job deploys to a specific [environment]. This allows easy tracking of all deployments to your environments straight from GitLab. If `environment` is specified and no environment under that name exists, a new one will be created automatically. -The `environment` name must contain only letters, digits, '-' and '_'. Common +The `environment` name must contain only letters, digits, '-', '_', '/', '$', '{', '}' and spaces. Common names are `qa`, `staging`, and `production`, but you can use whatever name works with your workflow. @@ -613,6 +565,37 @@ deploy to production: The `deploy to production` job will be marked as doing deployment to `production` environment. +#### dynamic environments + +> [Introduced][ce-6323] in GitLab 8.12 and GitLab Runner 1.6. + +`environment` can also represent a configuration hash with `name` and `url`. +These parameters can use any of the defined CI [variables](#variables) +(including predefined, secure variables and `.gitlab-ci.yml` variables). + +The common use case is to create dynamic environments for branches and use them +as review apps. + +--- + +**Example configurations** + +``` +deploy as review app: + stage: deploy + script: ... + environment: + name: review-apps/$CI_BUILD_REF_NAME + url: https://$CI_BUILD_REF_NAME.review.example.com/ +``` + +The `deploy as review app` job will be marked as deployment to dynamically +create the `review-apps/branch-name` environment. + +This environment should be accessible under `https://branch-name.review.example.com/`. + +You can see a simple example at https://gitlab.com/gitlab-examples/review-apps-nginx/. + ### artifacts >**Notes:** @@ -680,8 +663,7 @@ be available for download in the GitLab UI. #### artifacts:name ->**Note:** -Introduced in GitLab 8.6 and GitLab Runner v1.1.0. +> Introduced in GitLab 8.6 and GitLab Runner v1.1.0. The `name` directive allows you to define the name of the created artifacts archive. That way, you can have a unique name for every archive which could be @@ -744,8 +726,7 @@ job: #### artifacts:when ->**Note:** -Introduced in GitLab 8.9 and GitLab Runner v1.3.0. +> Introduced in GitLab 8.9 and GitLab Runner v1.3.0. `artifacts:when` is used to upload artifacts on build failure or despite the failure. @@ -770,8 +751,7 @@ job: #### artifacts:expire_in ->**Note:** -Introduced in GitLab 8.9 and GitLab Runner v1.3.0. +> Introduced in GitLab 8.9 and GitLab Runner v1.3.0. `artifacts:expire_in` is used to delete uploaded artifacts after the specified time. By default, artifacts are stored on GitLab forever. `expire_in` allows you @@ -806,8 +786,7 @@ job: ### dependencies ->**Note:** -Introduced in GitLab 8.6 and GitLab Runner v1.1.1. +> Introduced in GitLab 8.6 and GitLab Runner v1.1.1. This feature should be used in conjunction with [`artifacts`](#artifacts) and allows you to define the artifacts to pass between different builds. @@ -881,32 +860,48 @@ job: ## Git Strategy ->**Note:** -Introduced in GitLab 8.9 as an experimental feature. May change in future -releases or be removed completely. +> Introduced in GitLab 8.9 as an experimental feature. May change or be removed + completely in future releases. `GIT_STRATEGY=none` requires GitLab Runner + v1.7+. -You can set the `GIT_STRATEGY` used for getting recent application code. `clone` -is slower, but makes sure you have a clean directory before every build. `fetch` -is faster. `GIT_STRATEGY` can be specified in the global `variables` section or -in the `variables` section for individual jobs. If it's not specified, then the -default from project settings will be used. +You can set the `GIT_STRATEGY` used for getting recent application code, either +in the global [`variables`](#variables) section or the [`variables`](#job-variables) +section for individual jobs. If left unspecified, the default from project +settings will be used. + +There are three possible values: `clone`, `fetch`, and `none`. + +`clone` is the slowest option. It clones the repository from scratch for every +job, ensuring that the project workspace is always pristine. ``` variables: GIT_STRATEGY: clone ``` -or +`fetch` is faster as it re-uses the project workspace (falling back to `clone` +if it doesn't exist). `git clean` is used to undo any changes made by the last +job, and `git fetch` is used to retrieve commits made since the last job ran. ``` variables: GIT_STRATEGY: fetch ``` +`none` also re-uses the project workspace, but skips all Git operations +(including GitLab Runner's pre-clone script, if present). It is mostly useful +for jobs that operate exclusively on artifacts (e.g., `deploy`). Git repository +data may be present, but it is certain to be out of date, so you should only +rely on files brought into the project workspace from cache or artifacts. + +``` +variables: + GIT_STRATEGY: none +``` + ## Shallow cloning ->**Note:** -Introduced in GitLab 8.9 as an experimental feature. May change in future +> Introduced in GitLab 8.9 as an experimental feature. May change in future releases or be removed completely. You can specify the depth of fetching and cloning using `GIT_DEPTH`. This allows @@ -934,24 +929,26 @@ variables: GIT_DEPTH: "3" ``` -## Hidden jobs +## Hidden keys ->**Note:** -Introduced in GitLab 8.6 and GitLab Runner v1.1.1. +> Introduced in GitLab 8.6 and GitLab Runner v1.1.1. -Jobs that start with a dot (`.`) will be not processed by GitLab CI. You can +Keys that start with a dot (`.`) will be not processed by GitLab CI. You can use this feature to ignore jobs, or use the -[special YAML features](#special-yaml-features) and transform the hidden jobs +[special YAML features](#special-yaml-features) and transform the hidden keys into templates. -In the following example, `.job_name` will be ignored: +In the following example, `.key_name` will be ignored: ```yaml -.job_name: +.key_name: script: - rake spec ``` +Hidden keys can be hashes like normal CI jobs, but you are also allowed to use +different types of structures to leverage special YAML features. + ## Special YAML features It's possible to use special YAML features like anchors (`&`), aliases (`*`) @@ -962,12 +959,11 @@ Read more about the various [YAML features](https://learnxinyminutes.com/docs/ya ### Anchors ->**Note:** -Introduced in GitLab 8.6 and GitLab Runner v1.1.1. +> Introduced in GitLab 8.6 and GitLab Runner v1.1.1. YAML also has a handy feature called 'anchors', which let you easily duplicate content across your document. Anchors can be used to duplicate/inherit -properties, and is a perfect example to be used with [hidden jobs](#hidden-jobs) +properties, and is a perfect example to be used with [hidden keys](#hidden-keys) to provide templates for your jobs. The following example uses anchors and map merging. It will create two jobs, @@ -975,7 +971,7 @@ The following example uses anchors and map merging. It will create two jobs, having their own custom `script` defined: ```yaml -.job_template: &job_definition # Hidden job that defines an anchor named 'job_definition' +.job_template: &job_definition # Hidden key that defines an anchor named 'job_definition' image: ruby:2.1 services: - postgres @@ -1081,7 +1077,14 @@ test:mysql: - ruby ``` -You can see that the hidden jobs are conveniently used as templates. +You can see that the hidden keys are conveniently used as templates. + +## Triggers + +Triggers can be used to force a rebuild of a specific branch, tag or commit, +with an API call. + +[Read more in the triggers documentation.](../triggers/README.md) ## Validate the .gitlab-ci.yml @@ -1099,3 +1102,5 @@ Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. [examples]: ../examples/README.md +[ce-6323]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6323 +[environment]: ../environments.md diff --git a/doc/container_registry/README.md b/doc/container_registry/README.md index 3db351811a8..fe3e4681ba7 100644 --- a/doc/container_registry/README.md +++ b/doc/container_registry/README.md @@ -1,99 +1 @@ -# GitLab Container Registry - -> **Note:** -This feature was [introduced][ce-4040] in GitLab 8.8. Docker Registry manifest -v1 support was added in GitLab 8.9 to support Docker versions earlier than 1.10. - -> **Note:** -This document is about the user guide. To learn how to enable GitLab Container -Registry across your GitLab instance, visit the -[administrator documentation](../administration/container_registry.md). - -With the Docker Container Registry integrated into GitLab, every project can -have its own space to store its Docker images. - -You can read more about Docker Registry at https://docs.docker.com/registry/introduction/. - ---- - -## Enable the Container Registry for your project - -1. First, ask your system administrator to enable GitLab Container Registry - following the [administration documentation](../administration/container_registry.md). - If you are using GitLab.com, this is enabled by default so you can start using - the Registry immediately. - -1. Go to your project's settings and enable the **Container Registry** feature - on your project. For new projects this might be enabled by default. For - existing projects you will have to explicitly enable it. - -  - -## Build and push images - -After you save your project's settings, you should see a new link in the -sidebar called **Container Registry**. Following this link will get you to -your project's Registry panel where you can see how to login to the Container -Registry using your GitLab credentials. - -For example if the Registry's URL is `registry.example.com`, the you should be -able to login with: - -``` -docker login registry.example.com -``` - -Building and publishing images should be a straightforward process. Just make -sure that you are using the Registry URL with the namespace and project name -that is hosted on GitLab: - -``` -docker build -t registry.example.com/group/project . -docker push registry.example.com/group/project -``` - -## Use images from GitLab Container Registry - -To download and run a container from images hosted in GitLab Container Registry, -use `docker run`: - -``` -docker run [options] registry.example.com/group/project [arguments] -``` - -For more information on running Docker containers, visit the -[Docker documentation][docker-docs]. - -## Control Container Registry from within GitLab - -GitLab offers a simple Container Registry management panel. Go to your project -and click **Container Registry** in the left sidebar. - -This view will show you all tags in your project and will easily allow you to -delete them. - - - -## Build and push images using GitLab CI - -> **Note:** -This feature requires GitLab 8.8 and GitLab Runner 1.2. - -Make sure that your GitLab Runner is configured to allow building docker images. -You have to check the [Using Docker Build documentation](../ci/docker/using_docker_build.md). -Then see the CI documentation on [Using the GitLab Container Registry](../ci/docker/using_docker_build.md#using-the-gitlab-container-registry). - -## Limitations - -In order to use a container image from your private project as an `image:` in -your `.gitlab-ci.yml`, you have to follow the -[Using a private Docker Registry][private-docker] -documentation. This workflow will be simplified in the future. - -## Troubleshooting - -See [the GitLab Docker registry troubleshooting guide](troubleshooting.md). - -[ce-4040]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4040 -[docker-docs]: https://docs.docker.com/engine/userguide/intro/ -[private-docker]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/configuration/advanced-configuration.md#using-a-private-docker-registry +This document was moved in [user/project/container_registry](../user/project/container_registry.md). diff --git a/doc/container_registry/img/container_registry.png b/doc/container_registry/img/container_registry.png Binary files differdeleted file mode 100644 index 57d6f9f22c5..00000000000 --- a/doc/container_registry/img/container_registry.png +++ /dev/null diff --git a/doc/container_registry/img/project_feature.png b/doc/container_registry/img/project_feature.png Binary files differdeleted file mode 100644 index a59b4f82b56..00000000000 --- a/doc/container_registry/img/project_feature.png +++ /dev/null diff --git a/doc/container_registry/troubleshooting.md b/doc/container_registry/troubleshooting.md index 14c4a7d9a63..2f8cd37b488 100644 --- a/doc/container_registry/troubleshooting.md +++ b/doc/container_registry/troubleshooting.md @@ -1,141 +1 @@ -# Troubleshooting the GitLab Container Registry - -## Basic Troubleshooting - -1. Check to make sure that the system clock on your Docker client and GitLab server have - been synchronized (e.g. via NTP). - -2. If you are using an S3-backed Registry, double check that the IAM - permissions and the S3 credentials (including region) are correct. See [the - sample IAM policy](https://docs.docker.com/registry/storage-drivers/s3/) - for more details. - -3. Check the Registry logs (e.g. `/var/log/gitlab/registry/current`) and the GitLab production logs - for errors (e.g. `/var/log/gitlab/gitlab-rails/production.log`). You may be able to find clues - there. - -## Advanced Troubleshooting - ->**NOTE:** The following section is only recommended for experts. - -Sometimes it's not obvious what is wrong, and you may need to dive deeper into -the communication between the Docker client and the Registry to find out -what's wrong. We will use a concrete example in the past to illustrate how to -diagnose a problem with the S3 setup. - -### Unexpected 403 error during push - -A user attempted to enable an S3-backed Registry. The `docker login` step went -fine. However, when pushing an image, the output showed: - -``` -The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test] -dc5e59c14160: Pushing [==================================================>] 14.85 kB -03c20c1a019a: Pushing [==================================================>] 2.048 kB -a08f14ef632e: Pushing [==================================================>] 2.048 kB -228950524c88: Pushing 2.048 kB -6a8ecde4cc03: Pushing [==> ] 9.901 MB/205.7 MB -5f70bf18a086: Pushing 1.024 kB -737f40e80b7f: Waiting -82b57dbc5385: Waiting -19429b698a22: Waiting -9436069b92a3: Waiting -error parsing HTTP 403 response body: unexpected end of JSON input: "" -``` - -This error is ambiguous, as it's not clear whether the 403 is coming from the -GitLab Rails application, the Docker Registry, or something else. In this -case, since we know that since the login succeeded, we probably need to look -at the communication between the client and the Registry. - -The REST API between the Docker client and Registry is [described -here](https://docs.docker.com/registry/spec/api/). Normally, one would just -use Wireshark or tcpdump to capture the traffic and see where things went -wrong. However, since all communication between Docker clients and servers -are done over HTTPS, it's a bit difficult to decrypt the traffic quickly even -if you know the private key. What can we do instead? - -One way would be to disable HTTPS by setting up an [insecure -Registry](https://docs.docker.com/registry/insecure/). This could introduce a -security hole and is only recommended for local testing. If you have a -production system and can't or don't want to do this, there is another way: -use mitmproxy, which stands for Man-in-the-Middle Proxy. - -### mitmproxy - -[mitmproxy](https://mitmproxy.org/) allows you to place a proxy between your -client and server to inspect all traffic. One wrinkle is that your system -needs to trust the mitmproxy SSL certificates for this to work. - -The following installation instructions assume you are running Ubuntu: - -1. Install mitmproxy (see http://docs.mitmproxy.org/en/stable/install.html) -1. Run `mitmproxy --port 9000` to generate its certificates. - Enter <kbd>CTRL</kbd>-<kbd>C</kbd> to quit. -1. Install the certificate from `~/.mitmproxy` to your system: - - ```sh - sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt - sudo update-ca-certificates - ``` - -If successful, the output should indicate that a certificate was added: - -```sh -Updating certificates in /etc/ssl/certs... 1 added, 0 removed; done. -Running hooks in /etc/ca-certificates/update.d....done. -``` - -To verify that the certificates are properly installed, run: - -```sh -mitmproxy --port 9000 -``` - -This will run mitmproxy on port `9000`. In another window, run: - -```sh -curl --proxy http://localhost:9000 https://httpbin.org/status/200 -``` - -If everything is setup correctly, you will see information on the mitmproxy window and -no errors from the curl commands. - -### Running the Docker daemon with a proxy - -For Docker to connect through a proxy, you must start the Docker daemon with the -proper environment variables. The easiest way is to shutdown Docker (e.g. `sudo initctl stop docker`) -and then run Docker by hand. As root, run: - -```sh -export HTTP_PROXY="http://localhost:9000" -export HTTPS_PROXY="https://localhost:9000" -docker daemon --debug -``` - -This will launch the Docker daemon and proxy all connections through mitmproxy. - -### Running the Docker client - -Now that we have mitmproxy and Docker running, we can attempt to login and push -a container image. You may need to run as root to do this. For example: - -```sh -docker login s3-testing.myregistry.com:4567 -docker push s3-testing.myregistry.com:4567/root/docker-test -``` - -In the example above, we see the following trace on the mitmproxy window: - - - -The above image shows: - -* The initial PUT requests went through fine with a 201 status code. -* The 201 redirected the client to the S3 bucket. -* The HEAD request to the AWS bucket reported a 403 Unauthorized. - -What does this mean? This strongly suggests that the S3 user does not have the right -[permissions to perform a HEAD request](http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html). -The solution: check the [IAM permissions again](https://docs.docker.com/registry/storage-drivers/s3/). -Once the right permissions were set, the error will go away. +This document was moved to [user/project/container_registry](../user/project/container_registry.md). diff --git a/doc/customization/issue_closing.md b/doc/customization/issue_closing.md index 4620bb2dcde..31164ccd465 100644 --- a/doc/customization/issue_closing.md +++ b/doc/customization/issue_closing.md @@ -1,39 +1,4 @@ -# Issue closing pattern +This document was split into: -When a commit or merge request resolves one or more issues, it is possible to automatically have these issues closed when the commit or merge request lands in the project's default branch. - -If a commit message or merge request description contains a sentence matching the regular expression below, all issues referenced from -the matched text will be closed. This happens when the commit is pushed to a project's default branch, or when a commit or merge request is merged into there. - -When not specified, the default `issue_closing_pattern` as shown below will be used: - -```bash -((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+) -``` - -Here, `%{issue_ref}` is a complex regular expression defined inside GitLab, that matches a reference to a local issue (`#123`), cross-project issue (`group/project#123`) or a link to an issue (`https://gitlab.example.com/group/project/issues/123`). - -For example: - -``` -git commit -m "Awesome commit message (Fix #20, Fixes #21 and Closes group/otherproject#22). This commit is also related to #17 and fixes #18, #19 and https://gitlab.example.com/group/otherproject/issues/23." -``` - -will close `#18`, `#19`, `#20`, and `#21` in the project this commit is pushed to, as well as `#22` and `#23` in group/otherproject. `#17` won't be closed as it does not match the pattern. It also works with multiline commit messages. - -Tip: you can test this closing pattern at [http://rubular.com][1]. Use this site -to test your own patterns. -Because Rubular doesn't understand `%{issue_ref}`, you can replace this by `#\d+` in testing, which matches only local issue references like `#123`. - -## Change the pattern - -For Omnibus installs you can change the default pattern in `/etc/gitlab/gitlab.rb`: - -``` -issue_closing_pattern: '((?:[Cc]los(?:e[sd]|ing)|[Ff]ix(?:e[sd]|ing)?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?))+)' -``` - -For manual installs you can customize the pattern in [gitlab.yml][0] using the `issue_closing_pattern` key. - -[0]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example -[1]: http://rubular.com/r/Xmbexed1OJ +- [administration/issue_closing_pattern.md](../administration/issue_closing_pattern.md). +- [user/project/issues/automatic_issue_closing](../user/project/issues/automatic_issue_closing.md). diff --git a/doc/development/README.md b/doc/development/README.md index 11aa50b89af..9706cb1de7f 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -4,21 +4,23 @@ - [CONTRIBUTING.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md) main contributing guide - [PROCESS.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/PROCESS.md) contributing process -- [GitLab Development Kit (GDK)](https://gitlab.com/gitlab-org/gitlab-development-kit) to install a development version +- [GitLab Development Kit (GDK)](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/master/doc/howto/README.md) to install a development version ## Styleguides -- [Documentation styleguide](development/doc_styleguide.md) Use this styleguide if you are +- [Documentation styleguide](doc_styleguide.md) Use this styleguide if you are contributing to documentation. -- [Migration Style Guide](migration_style_guide.md) for creating safe migrations +- [SQL Migration Style Guide](migration_style_guide.md) for creating safe SQL migrations - [Testing standards and style guidelines](testing.md) -- [UI guide](ui_guide.md) for building GitLab with existing css styles and elements +- [UI guide](ui_guide.md) for building GitLab with existing CSS styles and elements +- [Frontend guidelines](frontend.md) - [SQL guidelines](sql.md) for SQL guidelines - ## Process - [Code review guidelines](code_review.md) for reviewing code and having code reviewed. +- [Merge request performance guidelines](merge_request_performance_guidelines.md) + for ensuring merge requests do not negatively impact GitLab performance ## Backend howtos @@ -32,6 +34,11 @@ - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) +## Databases + +- [What requires downtime?](what_requires_downtime.md) +- [Adding database indexes](adding_database_indexes.md) + ## Compliance - [Licensing](licensing.md) for ensuring license compliance diff --git a/doc/development/adding_database_indexes.md b/doc/development/adding_database_indexes.md new file mode 100644 index 00000000000..ea6f14da3b9 --- /dev/null +++ b/doc/development/adding_database_indexes.md @@ -0,0 +1,123 @@ +# Adding Database Indexes + +Indexes can be used to speed up database queries, but when should you add a new +index? Traditionally the answer to this question has been to add an index for +every column used for filtering or joining data. For example, consider the +following query: + +```sql +SELECT * +FROM projects +WHERE user_id = 2; +``` + +Here we are filtering by the `user_id` column and as such a developer may decide +to index this column. + +While in certain cases indexing columns using the above approach may make sense +it can actually have a negative impact. Whenever you write data to a table any +existing indexes need to be updated. The more indexes there are the slower this +can potentially become. Indexes can also take up quite some disk space depending +on the amount of data indexed and the index type. For example, PostgreSQL offers +"GIN" indexes which can be used to index certain data types that can not be +indexed by regular btree indexes. These indexes however generally take up more +data and are slower to update compared to btree indexes. + +Because of all this one should not blindly add a new index for every column used +to filter data by. Instead one should ask themselves the following questions: + +1. Can I write my query in such a way that it re-uses as many existing indexes + as possible? +2. Is the data going to be large enough that using an index will actually be + faster than just iterating over the rows in the table? +3. Is the overhead of maintaining the index worth the reduction in query + timings? + +We'll explore every question in detail below. + +## Re-using Queries + +The first step is to make sure your query re-uses as many existing indexes as +possible. For example, consider the following query: + +```sql +SELECT * +FROM todos +WHERE user_id = 123 +AND state = 'open'; +``` + +Now imagine we already have an index on the `user_id` column but not on the +`state` column. One may think this query will perform badly due to `state` being +unindexed. In reality the query may perform just fine given the index on +`user_id` can filter out enough rows. + +The best way to determine if indexes are re-used is to run your query using +`EXPLAIN ANALYZE`. Depending on any extra tables that may be joined and +other columns being used for filtering you may find an extra index is not going +to make much (if any) difference. On the other hand you may determine that the +index _may_ make a difference. + +In short: + +1. Try to write your query in such a way that it re-uses as many existing + indexes as possible. +2. Run the query using `EXPLAIN ANALYZE` and study the output to find the most + ideal query. + +## Data Size + +A database may decide not to use an index despite it existing in case a regular +sequence scan (= simply iterating over all existing rows) is faster. This is +especially the case for small tables. + +If a table is expected to grow in size and you expect your query has to filter +out a lot of rows you may want to consider adding an index. If the table size is +very small (e.g. only a handful of rows) or any existing indexes filter out +enough rows you may _not_ want to add a new index. + +## Maintenance Overhead + +Indexes have to be updated on every table write. In case of PostgreSQL _all_ +existing indexes will be updated whenever data is written to a table. As a +result of this having many indexes on the same table will slow down writes. + +Because of this one should ask themselves: is the reduction in query performance +worth the overhead of maintaining an extra index? + +If adding an index reduces SELECT timings by 5 milliseconds but increases +INSERT/UPDATE/DELETE timings by 10 milliseconds then the index may not be worth +it. On the other hand, if SELECT timings are reduced but INSERT/UPDATE/DELETE +timings are not affected you may want to add the index after all. + +## Finding Unused Indexes + +To see which indexes are unused you can run the following query: + +```sql +SELECT relname as table_name, indexrelname as index_name, idx_scan, idx_tup_read, idx_tup_fetch, pg_size_pretty(pg_relation_size(indexrelname::regclass)) +FROM pg_stat_all_indexes +WHERE schemaname = 'public' +AND idx_scan = 0 +AND idx_tup_read = 0 +AND idx_tup_fetch = 0 +ORDER BY pg_relation_size(indexrelname::regclass) desc; +``` + +This query outputs a list containing all indexes that are never used and sorts +them by indexes sizes in descending order. This query can be useful to +determine if any previously indexes are useful after all. More information on +the meaning of the various columns can be found at +<https://www.postgresql.org/docs/current/static/monitoring-stats.html>. + +Because the output of this query relies on the actual usage of your database it +may be affected by factors such as (but not limited to): + +* Certain queries never being executed, thus not being able to use certain + indexes. +* Certain tables having little data, resulting in PostgreSQL using sequence + scans instead of index scans. + +In other words, this data is only reliable for a frequently used database with +plenty of data and with as many GitLab features enabled (and being used) as +possible. diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 40ae55ab905..c5c23b5c0b8 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -34,6 +34,10 @@ request is up to one of our merge request "endbosses", denoted on the ## Having your code reviewed +Please keep in mind that code review is a process that can take multiple +iterations, and reviewers may spot things later that they may not have seen the +first time. + - The first reviewer of your code is _you_. Before you perform that first push of your shiny new branch, read through the entire diff. Does it make sense? Did you include something unrelated to the overall purpose of the changes? Did @@ -55,6 +59,7 @@ request is up to one of our merge request "endbosses", denoted on the Understand why the change is necessary (fixes a bug, improves the user experience, refactors the existing code). Then: +- Try to be thorough in your reviews to reduce the number of iterations. - Communicate which ideas you feel strongly about and those you don't. - Identify ways to simplify the code while still solving the problem. - Offer alternative implementations, but assume the author already considered @@ -64,8 +69,10 @@ experience, refactors the existing code). Then: someone else would be confused by it as well. - After a round of line notes, it can be helpful to post a summary note such as "LGTM :thumbsup:", or "Just a couple things to address." -- Avoid accepting a merge request before the build succeeds ("Merge when build - succeeds" is fine). +- Avoid accepting a merge request before the build succeeds. Of course, "Merge + When Build Succeeds" (MWBS) is fine. +- If you set the MR to "Merge When Build Succeeds", you should take over + subsequent revisions for anything that would be spotted after that. ## Credits diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 3a3597bccaa..0b725cf200c 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -3,12 +3,64 @@ This styleguide recommends best practices to improve documentation and to keep it organized and easy to find. -## Naming +## Location and naming of documents -- When creating a new document and it has more than one word in its name, - make sure to use underscores instead of spaces or dashes (`-`). For example, - a proper naming would be `import_projects_from_github.md`. The same rule - applies to images. +>**Note:** +These guidelines derive from the discussion taken place in issue [#3349][ce-3349]. + +The documentation hierarchy can be vastly improved by providing a better layout +and organization of directories. + +Having a structured document layout, we will be able to have meaningful URLs +like `docs.gitlab.com/user/project/merge_requests.html`. With this pattern, +you can immediately tell that you are navigating a user related documentation +and is about the project and its merge requests. + +The table below shows what kind of documentation goes where. + +| Directory | What belongs here | +| --------- | -------------- | +| `doc/user/` | User related documentation. Anything that can be done within the GitLab UI goes here including `/admin`. | +| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. The admin settings that can be accessed via GitLab's interface go under `doc/user/admin_area/`. | +| `doc/api/` | API related documentation. | +| `doc/development/` | Documentation related to the development of GitLab. Any styleguides should go here. | +| `doc/legal/` | Legal documents about contributing to GitLab. | +| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). | +| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. | + +--- + +**General rules:** + +1. The correct naming and location of a new document, is a combination + of the relative URL of the document in question and the GitLab Map design + that is used for UX purposes ([source][graffle], [image][gitlab-map]). +1. When creating a new document and it has more than one word in its name, + make sure to use underscores instead of spaces or dashes (`-`). For example, + a proper naming would be `import_projects_from_github.md`. The same rule + applies to images. +1. There are four main directories, `user`, `administration`, `api` and `development`. +1. The `doc/user/` directory has five main subdirectories: `project/`, `group/`, + `profile/`, `dashboard/` and `admin_area/`. + 1. `doc/user/project/` should contain all project related documentation. + 1. `doc/user/group/` should contain all group related documentation. + 1. `doc/user/profile/` should contain all profile related documentation. + Every page you would navigate under `/profile` should have its own document, + i.e. `account.md`, `applications.md`, `emails.md`, etc. + 1. `doc/user/dashboard/` should contain all dashboard related documentation. + 1. `doc/user/admin_area/` should contain all admin related documentation + describing what can be achieved by accessing GitLab's admin interface + (_not to be confused with `doc/administration` where server access is + required_). + 1. Every category under `/admin/application_settings` should have its + own document located at `doc/user/admin_area/settings/`. For example, + the **Visibility and Access Controls** category should have a document + located at `doc/user/admin_area/settings/visibility_and_access_controls.md`. + +--- + +If you are unsure where a document should live, you can ping `@axil` in your +merge request. ## Text @@ -103,15 +155,30 @@ Inside the document: - Every piece of documentation that comes with a new feature should declare the GitLab version that feature got introduced. Right below the heading add a - note: `>**Note:** This feature was introduced in GitLab 8.3` + note: + + ``` + > Introduced in GitLab 8.3. + ``` + - If possible every feature should have a link to the MR that introduced it. The above note would be then transformed to: - `>**Note:** This feature was [introduced][ce-1242] in GitLab 8.3`, where - the [link identifier](#links) is named after the repository (CE) and the MR - number -- If the feature is only in GitLab EE, don't forget to mention it, like: - `>**Note:** This feature was introduced in GitLab EE 8.3`. Otherwise, leave - this mention out + + ``` + > [Introduced][ce-1242] in GitLab 8.3. + ``` + + , where the [link identifier](#links) is named after the repository (CE) and + the MR number. + +- If the feature is only in GitLab Enterprise Edition, don't forget to mention + it, like: + + ``` + > Introduced in GitLab Enterprise Edition 8.3. + ``` + + Otherwise, leave this mention out. ## References @@ -170,18 +237,26 @@ For example, if you were to move `doc/workflow/lfs/lfs_administration.md` to ``` 1. Find and replace any occurrences of the old location with the new one. - A quick way to find them is to use `grep`: + A quick way to find them is to use `git grep`. First go to the root directory + where you cloned the `gitlab-ce` repository and then do: ``` - grep -nR "lfs_administration.md" doc/ + git grep -n "workflow/lfs/lfs_administration" + git grep -n "lfs/lfs_administration" ``` - The above command will search in the `doc/` directory for - `lfs_administration.md` recursively and will print the file and the line - where this file is mentioned. Note that we used just the filename - (`lfs_administration.md`) and not the whole the relative path - (`workflow/lfs/lfs_administration.md`). +Things to note: +- Since we also use inline documentation, except for the documentation itself, + the document might also be referenced in the views of GitLab (`app/`) which will + render when visiting `/help`, and sometimes in the testing suite (`spec/`). +- The above `git grep` command will search recursively in the directory you run + it in for `workflow/lfs/lfs_administration` and `lfs/lfs_administration` + and will print the file and the line where this file is mentioned. + You may ask why the two greps. Since we use relative paths to link to + documentation, sometimes it might be useful to search a path deeper. +- The `*.md` extension is not used when a document is linked to GitLab's + built-in help page, that's why we omit it in `git grep`. ## Configuration documentation for source and Omnibus installations @@ -239,6 +314,29 @@ In this case: - different highlighting languages are used for each config in the code block - the [references](#references) guide is used for reconfigure/restart +## Fake tokens + +There may be times where a token is needed to demonstrate an API call using +cURL or a secret variable used in CI. It is strongly advised not to use real +tokens in documentation even if the probability of a token being exploited is +low. + +You can use the following fake tokens as examples. + +| **Token type** | **Token value** | +| --------------------- | --------------------------------- | +| Private user token | `9koXpg98eAheJpvBs5tK` | +| Personal access token | `n671WNGecHugsdEDPsyo` | +| Application ID | `2fcb195768c39e9a94cec2c2e32c59c0aad7a3365c10892e8116b5d83d4096b6` | +| Application secret | `04f294d1eaca42b8692017b426d53bbc8fe75f827734f0260710b83a556082df` | +| Secret CI variable | `Li8j-mLUVA3eZYjPfd_H` | +| Specific Runner token | `yrnZW46BrtBFqM7xDzE7dddd` | +| Shared Runner token | `6Vk7ZsosqQyfreAxXTZr` | +| Trigger token | `be20d8dcc028677c931e04f3871a9b` | +| Webhook secret token | `6XhDroRcYPM5by_h-HLY` | +| Health check token | `Tu7BgjR9qeZTEyRzGG2P` | +| Request profile token | `7VgpS4Ax5utVD2esNstz` | + ## API Here is a list of must-have items. Use them in the exact order that appears @@ -303,7 +401,7 @@ Below is a set of [cURL][] examples that you can use in the API documentation. Get the details of a group: ```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/gitlab-org +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/groups/gitlab-org ``` #### cURL example with parameters passed in the URL @@ -311,7 +409,7 @@ curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/ Create a new project under the authenticated user's namespace: ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects?name=foo" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects?name=foo" ``` #### Post data using cURL's --data @@ -321,7 +419,7 @@ cURL's `--data` option. The example below will create a new project `foo` under the authenticated user's namespace. ```bash -curl --data "name=foo" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects" +curl --data "name=foo" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects" ``` #### Post data using JSON content @@ -330,7 +428,7 @@ curl --data "name=foo" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab. and double quotes. ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -H "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' https://gitlab.example.com/api/v3/groups +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --header "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' https://gitlab.example.com/api/v3/groups ``` #### Post data using form-data @@ -339,7 +437,7 @@ Instead of using JSON or urlencode you can use multipart/form-data which properly handles data encoding: ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -F "title=ssh-key" -F "key=ssh-rsa AAAAB3NzaC1yc2EA..." https://gitlab.example.com/api/v3/users/25/keys +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --form "title=ssh-key" --form "key=ssh-rsa AAAAB3NzaC1yc2EA..." https://gitlab.example.com/api/v3/users/25/keys ``` The above example is run by and administrator and will add an SSH public key @@ -353,7 +451,7 @@ contains spaces in its title. Observe how spaces are escaped using the `%20` ASCII code. ```bash -curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/42/issues?title=Hello%20Dude" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/42/issues?title=Hello%20Dude" ``` Use `%2F` for slashes (`/`). @@ -365,10 +463,13 @@ restrict the sign-up e-mail domains of a GitLab instance to `*.example.com` and `example.net`, you would do something like this: ```bash -curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -d "domain_whitelist[]=*.example.com" -d "domain_whitelist[]=example.net" https://gitlab.example.com/api/v3/application/settings +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "domain_whitelist[]=*.example.com" --data "domain_whitelist[]=example.net" https://gitlab.example.com/api/v3/application/settings ``` [cURL]: http://curl.haxx.se/ "cURL website" [single spaces]: http://www.slate.com/articles/technology/technology/2011/01/space_invaders.html -[gfm]: http://docs.gitlab.com/ce/markdown/markdown.html#newlines "GitLab flavored markdown documentation" +[gfm]: http://docs.gitlab.com/ce/user/markdown.html#newlines "GitLab flavored markdown documentation" [doc-restart]: ../administration/restart_gitlab.md "GitLab restart documentation" +[ce-3349]: https://gitlab.com/gitlab-org/gitlab-ce/issues/3349 "Documentation restructure" +[graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/d8d39f4a87b90fb9ae89ca12dc565347b4900d5e/production/resources/gitlab-map.graffle +[gitlab-map]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png diff --git a/doc/development/frontend.md b/doc/development/frontend.md new file mode 100644 index 00000000000..56c8516508e --- /dev/null +++ b/doc/development/frontend.md @@ -0,0 +1,236 @@ +# Frontend Development Guidelines + +This document describes various guidelines to ensure consistency and quality +across GitLab's frontend team. + +## Overview + +GitLab is built on top of [Ruby on Rails][rails] using [Haml][haml] with +[Hamlit][hamlit]. Be wary of [the limitations that come with using +Hamlit][hamlit-limits]. We also use [SCSS][scss] and plain JavaScript with +[ES6 by way of Babel][es6]. + +The asset pipeline is [Sprockets][sprockets], which handles the concatenation, +minification, and compression of our assets. + +[jQuery][jquery] is used throughout the application's JavaScript, with +[Vue.js][vue] for particularly advanced, dynamic elements. + +### Vue + +For more complex frontend features, we recommend using Vue.js. It shares +some ideas with React.js as well as Angular. + +To get started with Vue, read through [their documentation][vue-docs]. + +## Performance + +### Resources + +- [WebPage Test][web-page-test] for testing site loading time and size. +- [Google PageSpeed Insights][pagespeed-insights] grades web pages and provides feedback to improve the page. +- [Profiling with Chrome DevTools][google-devtools-profiling] +- [Browser Diet][browser-diet] is a community-built guide that catalogues practical tips for improving web page performance. + +### Page-specific JavaScript + +Certain pages may require the use of a third party library, such as [d3][d3] for +the User Activity Calendar and [Chart.js][chartjs] for the Graphs pages. These +libraries increase the page size significantly, and impact load times due to +bandwidth bottlenecks and the browser needing to parse more JavaScript. + +In cases where libraries are only used on a few specific pages, we use +"page-specific JavaScript" to prevent the main `application.js` file from +becoming unnecessarily large. + +Steps to split page-specific JavaScript from the main `application.js`: + +1. Create a directory for the specific page(s), e.g. `graphs/`. +1. In that directory, create a `namespace_bundle.js` file, e.g. `graphs_bundle.js`. +1. In `graphs_bundle.js` add the line `//= require_tree .`, this adds all other files in the directory to the bundle. +1. Add any necessary libraries to `app/assets/javascripts/lib/`, all files directly descendant from this directory will be precompiled as separate assets, in this case `chart.js` would be added. +1. Add the new "bundle" file to the list of precompiled assets in +`config/application.rb`. + - For example: `config.assets.precompile << "graphs/graphs_bundle.js"`. +1. Move code reliant on these libraries into the `graphs` directory. +1. In the relevant views, add the scripts to the page with the following: + +```haml +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/chart.js') + = page_specific_javascript_tag('graphs/graphs_bundle.js') +``` + +The above loads `chart.js` and `graphs_bundle.js` for this page only. `chart.js` +is separated from the bundle file so it can be cached separately from the bundle +and reused for other pages that also rely on the library. For an example, see +[this Haml file][page-specific-js-example]. + +### Minimizing page size + +A smaller page size means the page loads faster (especially important on mobile +and poor connections), the page is parsed more quickly by the browser, and less +data is used for users with capped data plans. + +General tips: + +- Don't add new fonts. +- Prefer font formats with better compression, e.g. WOFF2 is better than WOFF, which is better than TTF. +- Compress and minify assets wherever possible (For CSS/JS, Sprockets does this for us). +- If some functionality can reasonably be achieved without adding extra libraries, avoid them. +- Use page-specific JavaScript as described above to dynamically load libraries that are only needed on certain pages. + +## Accessibility + +### Resources + +[Chrome Accessibility Developer Tools][chrome-accessibility-developer-tools] +are useful for testing for potential accessibility problems in GitLab. + +Accessibility best-practices and more in-depth information is available on +[the Audit Rules page][audit-rules] for the Chrome Accessibility Developer Tools. + +## Security + +### Resources + +[Mozilla’s HTTP Observatory CLI][observatory-cli] and the +[Qualys SSL Labs Server Test][qualys-ssl] are good resources for finding +potential problems and ensuring compliance with security best practices. + +<!-- Uncomment these sections when CSP/SRI are implemented. +### Content Security Policy (CSP) + +Content Security Policy is a web standard that intends to mitigate certain +forms of Cross-Site Scripting (XSS) as well as data injection. + +Content Security Policy rules should be taken into consideration when +implementing new features, especially those that may rely on connection with +external services. + +GitLab's CSP is used for the following: + +- Blocking plugins like Flash and Silverlight from running at all on our pages. +- Blocking the use of scripts and stylesheets downloaded from external sources. +- Upgrading `http` requests to `https` when possible. +- Preventing `iframe` elements from loading in most contexts. + +Some exceptions include: + +- Scripts from Google Analytics and Piwik if either is enabled. +- Connecting with GitHub, Bitbucket, GitLab.com, etc. to allow project importing. +- Connecting with Google, Twitter, GitHub, etc. to allow OAuth authentication. + +We use [the Secure Headers gem][secure_headers] to enable Content +Security Policy headers in the GitLab Rails app. + +Some resources on implementing Content Security Policy: + +- [MDN Article on CSP][mdn-csp] +- [GitHub’s CSP Journey on the GitHub Engineering Blog][github-eng-csp] +- The Dropbox Engineering Blog's series on CSP: [1][dropbox-csp-1], [2][dropbox-csp-2], [3][dropbox-csp-3], [4][dropbox-csp-4] + +### Subresource Integrity (SRI) + +Subresource Integrity prevents malicious assets from being provided by a CDN by +guaranteeing that the asset downloaded is identical to the asset the server +is expecting. + +The Rails app generates a unique hash of the asset, which is used as the +asset's `integrity` attribute. The browser generates the hash of the asset +on-load and will reject the asset if the hashes do not match. + +All CSS and JavaScript assets should use Subresource Integrity. For implementation details, +see the documentation for [the Sprockets implementation of SRI][sprockets-sri]. + +Some resources on implementing Subresource Integrity: + +- [MDN Article on SRI][mdn-sri] +- [Subresource Integrity on the GitHub Engineering Blog][github-eng-sri] + +--> + +### Including external resources + +External fonts, CSS, and JavaScript should never be used with the exception of +Google Analytics and Piwik - and only when the instance has enabled it. Assets +should always be hosted and served locally from the GitLab instance. Embedded +resources via `iframes` should never be used except in certain circumstances +such as with ReCaptcha, which cannot be used without an `iframe`. + +### Avoiding inline scripts and styles + +In order to protect users from [XSS vulnerabilities][xss], we will disable inline scripts in the future using Content Security Policy. + +While inline scripts can be useful, they're also a security concern. If +user-supplied content is unintentionally left un-sanitized, malicious users can +inject scripts into the web app. + +Inline styles should be avoided in almost all cases, they should only be used +when no alternatives can be found. This allows reusability of styles as well as +readability. + +## Style guides and linting + +See the relevant style guides for our guidelines and for information on linting: + +- [SCSS][scss-style-guide] + +## Testing + +Feature tests need to be written for all new features. Regression tests +also need to be written for all bug fixes to prevent them from occurring +again in the future. + +See [the Testing Standards and Style Guidelines](testing.md) for more +information. + +## Supported browsers + +For our currently-supported browsers, see our [requirements][requirements]. + +[rails]: http://rubyonrails.org/ +[haml]: http://haml.info/ +[hamlit]: https://github.com/k0kubun/hamlit +[hamlit-limits]: https://github.com/k0kubun/hamlit/blob/master/REFERENCE.md#limitations +[scss]: http://sass-lang.com/ +[es6]: https://babeljs.io/ +[sprockets]: https://github.com/rails/sprockets +[jquery]: https://jquery.com/ +[vue]: http://vuejs.org/ +[vue-docs]: http://vuejs.org/guide/index.html +[web-page-test]: http://www.webpagetest.org/ +[pagespeed-insights]: https://developers.google.com/speed/pagespeed/insights/ +[google-devtools-profiling]: https://developers.google.com/web/tools/chrome-devtools/profile/?hl=en +[browser-diet]: https://browserdiet.com/ +[d3]: https://d3js.org/ +[chartjs]: http://www.chartjs.org/ +[page-specific-js-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/13bb9ed77f405c5f6ee4fdbc964ecf635c9a223f/app/views/projects/graphs/_head.html.haml#L6-8 +[chrome-accessibility-developer-tools]: https://github.com/GoogleChrome/accessibility-developer-tools +[audit-rules]: https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules +[observatory-cli]: https://github.com/mozilla/http-observatory-cli) +[qualys-ssl]: https://www.ssllabs.com/ssltest/analyze.html +[secure_headers]: https://github.com/twitter/secureheaders +[mdn-csp]: https://developer.mozilla.org/en-US/docs/Web/Security/CSP +[github-eng-csp]: http://githubengineering.com/githubs-csp-journey/ +[dropbox-csp-1]: https://blogs.dropbox.com/tech/2015/09/on-csp-reporting-and-filtering/ +[dropbox-csp-2]: https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ +[dropbox-csp-3]: https://blogs.dropbox.com/tech/2015/09/csp-the-unexpected-eval/ +[dropbox-csp-4]: https://blogs.dropbox.com/tech/2015/09/csp-third-party-integrations-and-privilege-separation/ +[mdn-sri]: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity +[github-eng-sri]: http://githubengineering.com/subresource-integrity/ +[sprockets-sri]: https://github.com/rails/sprockets-rails#sri-support +[xss]: https://en.wikipedia.org/wiki/Cross-site_scripting +[scss-style-guide]: scss_styleguide.md +[requirements]: ../install/requirements.md#supported-web-browsers + +## Common Errors + +### Rspec (Capybara/Poltergeist) chokes on general JavaScript errors + +If you see very generic JavaScript errors (e.g. `jQuery is undefined`) being thrown in tests, but +can't reproduce them manually, you may have included `ES6`-style JavaScript in files that don't +have the `.js.es6` file extension. Either use ES5-friendly JavaScript or rename the file you're +working in (`git mv <file>.js> <file.js.es6>`). + + diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md index 9d7fe7440d2..159d5ce286d 100644 --- a/doc/development/gotchas.md +++ b/doc/development/gotchas.md @@ -41,10 +41,10 @@ Rubocop](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/.rubocop.yml#L9 [Exception]: http://stackoverflow.com/q/10048173/223897 -## Don't use inline CoffeeScript in views +## Don't use inline CoffeeScript/JavaScript in views Using the inline `:coffee` or `:coffeescript` Haml filters comes with a -performance overhead. +performance overhead. Using inline JavaScript is not a good way to structure your code and should be avoided. _**Note:** We've [removed these two filters](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/initializers/hamlit.rb) in an initializer._ @@ -52,6 +52,7 @@ in an initializer._ ### Further reading - Pull Request: [Replace CoffeeScript block into JavaScript in Views](https://git.io/vztMu) +- Stack Overflow: [Why you should not write inline JavaScript](http://programmers.stackexchange.com/questions/86589/why-should-i-avoid-inline-scripting) - Stack Overflow: [Performance implications of using :coffescript filter inside HAML templates?](http://stackoverflow.com/a/17571242/223897) ## ID-based CSS selectors need to be a bit more specific diff --git a/doc/development/instrumentation.md b/doc/development/instrumentation.md index c2272ab0a2b..105e2f1242a 100644 --- a/doc/development/instrumentation.md +++ b/doc/development/instrumentation.md @@ -137,3 +137,18 @@ end ``` Here the final value of `sleep_real_time` will be `3`, _not_ `1`. + +## Tracking Custom Events + +Besides instrumenting code GitLab Performance Monitoring also supports tracking +of custom events. This is primarily intended to be used for tracking business +metrics such as the number of Git pushes, repository imports, and so on. + +To track a custom event simply call `Gitlab::Metrics.add_event` passing it an +event name and a custom set of (optional) tags. For example: + +```ruby +Gitlab::Metrics.add_event(:user_login, email: current_user.email) +``` + +Event names should be verbs such as `push_repository` and `remove_branch`. diff --git a/doc/development/licensing.md b/doc/development/licensing.md index 8c8c7486fff..05972b33fdb 100644 --- a/doc/development/licensing.md +++ b/doc/development/licensing.md @@ -54,6 +54,7 @@ Libraries with the following licenses are acceptable for use: - [BSD 2-Clause License][BSD-2-Clause]: A permissive (non-copyleft) license as defined by the Open Source Initiative. - [BSD 3-Clause License][BSD-3-Clause] (also known as New BSD or Modified BSD): A permissive (non-copyleft) license as defined by the Open Source Initiative - [ISC License][ISC] (also known as the OpenBSD License): A permissive (non-copyleft) license as defined by the Open Source Initiative. +- [Creative Commons Zero (CC0)][CC0]: A public domain dedication, recommended as a way to disclaim copyright on your work to the maximum extent possible. ## Unacceptable Licenses @@ -85,6 +86,7 @@ Gems which are included only in the "development" or "test" groups by Bundler ar [BSD-2-Clause]: https://opensource.org/licenses/BSD-2-Clause [BSD-3-Clause]: https://opensource.org/licenses/BSD-3-Clause [ISC]: https://opensource.org/licenses/ISC +[CC0]: https://creativecommons.org/publicdomain/zero/1.0/ [GPL]: http://choosealicense.com/licenses/gpl-3.0/ [GPLv2]: http://www.gnu.org/licenses/gpl-2.0.txt [GPLv3]: http://www.gnu.org/licenses/gpl-3.0.txt diff --git a/doc/development/merge_request_performance_guidelines.md b/doc/development/merge_request_performance_guidelines.md new file mode 100644 index 00000000000..0363bf8c1d5 --- /dev/null +++ b/doc/development/merge_request_performance_guidelines.md @@ -0,0 +1,171 @@ +# Merge Request Performance Guidelines + +To ensure a merge request does not negatively impact performance of GitLab +_every_ merge request **must** adhere to the guidelines outlined in this +document. There are no exceptions to this rule unless specifically discussed +with and agreed upon by merge request endbosses and performance specialists. + +To measure the impact of a merge request you can use +[Sherlock](profiling.md#sherlock). It's also highly recommended that you read +the following guides: + +* [Performance Guidelines](performance.md) +* [What requires downtime?](what_requires_downtime.md) + +## Impact Analysis + +**Summary:** think about the impact your merge request may have on performance +and those maintaining a GitLab setup. + +Any change submitted can have an impact not only on the application itself but +also those maintaining it and those keeping it up and running (e.g. production +engineers). As a result you should think carefully about the impact of your +merge request on not only the application but also on the people keeping it up +and running. + +Can the queries used potentially take down any critical services and result in +engineers being woken up in the night? Can a malicious user abuse the code to +take down a GitLab instance? Will my changes simply make loading a certain page +slower? Will execution time grow exponentially given enough load or data in the +database? + +These are all questions one should ask themselves before submitting a merge +request. It may sometimes be difficult to assess the impact, in which case you +should ask a performance specialist to review your code. See the "Reviewing" +section below for more information. + +## Performance Review + +**Summary:** ask performance specialists to review your code if you're not sure +about the impact. + +Sometimes it's hard to assess the impact of a merge request. In this case you +should ask one of the merge request (mini) endbosses to review your changes. You +can find a list of these endbosses at <https://about.gitlab.com/team/>. An +endboss in turn can request a performance specialist to review the changes. + +## Query Counts + +**Summary:** a merge request **should not** increase the number of executed SQL +queries unless absolutely necessary. + +The number of queries executed by the code modified or added by a merge request +must not increase unless absolutely necessary. When building features it's +entirely possible you will need some extra queries, but you should try to keep +this at a minimum. + +As an example, say you introduce a feature that updates a number of database +rows with the same value. It may be very tempting (and easy) to write this using +the following pseudo code: + +```ruby +objects_to_update.each do |object| + object.some_field = some_value + object.save +end +``` + +This will end up running one query for every object to update. This code can +easily overload a database given enough rows to update or many instances of this +code running in parallel. This particular problem is known as the +["N+1 query problem"](http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations). + +In this particular case the workaround is fairly easy: + +```ruby +objects_to_update.update_all(some_field: some_value) +``` + +This uses ActiveRecord's `update_all` method to update all rows in a single +query. This in turn makes it much harder for this code to overload a database. + +## Executing Queries in Loops + +**Summary:** SQL queries **must not** be executed in a loop unless absolutely +necessary. + +Executing SQL queries in a loop can result in many queries being executed +depending on the number of iterations in a loop. This may work fine for a +development environment with little data, but in a production environment this +can quickly spiral out of control. + +There are some cases where this may be needed. If this is the case this should +be clearly mentioned in the merge request description. + +## Eager Loading + +**Summary:** always eager load associations when retrieving more than one row. + +When retrieving multiple database records for which you need to use any +associations you **must** eager load these associations. For example, if you're +retrieving a list of blog posts and you want to display their authors you +**must** eager load the author associations. + +In other words, instead of this: + +```ruby +Post.all.each do |post| + puts post.author.name +end +``` + +You should use this: + +```ruby +Post.all.includes(:author).each do |post| + puts post.author.name +end +``` + +## Memory Usage + +**Summary:** merge requests **must not** increase memory usage unless absolutely +necessary. + +A merge request must not increase the memory usage of GitLab by more than the +absolute bare minimum required by the code. This means that if you have to parse +some large document (e.g. an HTML document) it's best to parse it as a stream +whenever possible, instead of loading the entire input into memory. Sometimes +this isn't possible, in that case this should be stated explicitly in the merge +request. + +## Lazy Rendering of UI Elements + +**Summary:** only render UI elements when they're actually needed. + +Certain UI elements may not always be needed. For example, when hovering over a +diff line there's a small icon displayed that can be used to create a new +comment. Instead of always rendering these kind of elements they should only be +rendered when actually needed. This ensures we don't spend time generating +Haml/HTML when it's not going to be used. + +## Instrumenting New Code + +**Summary:** always add instrumentation for new classes, modules, and methods. + +Newly added classes, modules, and methods must be instrumented. This ensures +we can track the performance of this code over time. + +For more information see [Instrumentation](instrumentation.md). This guide +describes how to add instrumentation and where to add it. + +## Use of Caching + +**Summary:** cache data in memory or in Redis when it's needed multiple times in +a transaction or has to be kept around for a certain time period. + +Sometimes certain bits of data have to be re-used in different places during a +transaction. In these cases this data should be cached in memory to remove the +need for running complex operations to fetch the data. You should use Redis if +data should be cached for a certain time period instead of the duration of the +transaction. + +For example, say you process multiple snippets of text containiner username +mentions (e.g. `Hello @alice` and `How are you doing @alice?`). By caching the +user objects for every username we can remove the need for running the same +query for every mention of `@alice`. + +Caching data per transaction can be done using +[RequestStore](https://github.com/steveklabnik/request_store). Caching data in +Redis can be done using [Rails' caching +system](http://guides.rubyonrails.org/caching_with_rails.html). diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index b8fab3aaff7..61b0fbc89c9 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -9,10 +9,10 @@ a big burden for most organizations. For this reason it is important that your migrations are written carefully, can be applied online and adhere to the style guide below. Migrations should not require GitLab installations to be taken offline unless -_absolutely_ necessary. If a migration requires downtime this should be -clearly mentioned during the review process as well as being documented in the -monthly release post. For more information see the "Downtime Tagging" section -below. +_absolutely_ necessary - see the ["What Requires Downtime?"](what_requires_downtime.md) +page. If a migration requires downtime, this should be clearly mentioned during +the review process, as well as being documented in the monthly release post. For +more information, see the "Downtime Tagging" section below. When writing your migrations, also consider that databases might have stale data or inconsistencies and guard for that. Try to make as little assumptions as possible @@ -111,6 +111,28 @@ class MyMigration < ActiveRecord::Migration end ``` + +## Integer column type + +By default, an integer column can hold up to a 4-byte (32-bit) number. That is +a max value of 2,147,483,647. Be aware of this when creating a column that will +hold file sizes in byte units. If you are tracking file size in bytes this +restricts the maximum file size to just over 2GB. + +To allow an integer column to hold up to an 8-byte (64-bit) number, explicitly +set the limit to 8-bytes. This will allow the column to hold a value up to +9,223,372,036,854,775,807. + +Rails migration example: + +``` +add_column_with_default(:projects, :foo, :integer, default: 10, limit: 8) + +# or + +add_column(:projects, :foo, :integer, default: 10, limit: 8) +``` + ## Testing Make sure that your migration works with MySQL and PostgreSQL with data. An empty database does not guarantee that your migration is correct. diff --git a/doc/development/newlines_styleguide.md b/doc/development/newlines_styleguide.md new file mode 100644 index 00000000000..32aac2529a4 --- /dev/null +++ b/doc/development/newlines_styleguide.md @@ -0,0 +1,102 @@ +# Newlines styleguide + +This style guide recommends best practices for newlines in Ruby code. + +## Rule: separate code with newlines only to group together related logic + +```ruby +# bad +def method + issue = Issue.new + + issue.save + + render json: issue +end +``` + +```ruby +# good +def method + issue = Issue.new + issue.save + + render json: issue +end +``` + +## Rule: separate code and block with newlines + +### Newline before block + +```ruby +# bad +def method + issue = Issue.new + if issue.save + render json: issue + end +end +``` + +```ruby +# good +def method + issue = Issue.new + + if issue.save + render json: issue + end +end +``` + +## Newline after block + +```ruby +# bad +def method + if issue.save + issue.send_email + end + render json: issue +end +``` + +```ruby +# good +def method + if issue.save + issue.send_email + end + + render json: issue +end +``` + +### Exception: no need for newline when code block starts or ends right inside another code block + +```ruby +# bad +def method + + if issue + + if issue.valid? + issue.save + end + + end + +end +``` + +```ruby +# good +def method + if issue + if issue.valid? + issue.save + end + end +end +``` diff --git a/doc/development/performance.md b/doc/development/performance.md index fb37b3a889c..7ff603e2c4a 100644 --- a/doc/development/performance.md +++ b/doc/development/performance.md @@ -15,8 +15,8 @@ The process of solving performance problems is roughly as follows: 3. Add your findings based on the measurement period (screenshots of graphs, timings, etc) to the issue mentioned in step 1. 4. Solve the problem. -5. Create a merge request, assign the "performance" label and ping the right - people (e.g. [@yorickpeterse][yorickpeterse] and [@joshfng][joshfng]). +5. Create a merge request, assign the "Performance" label and assign it to + [@yorickpeterse][yorickpeterse] for reviewing. 6. Once a change has been deployed make sure to _again_ measure for at least 24 hours to see if your changes have any impact on the production environment. 7. Repeat until you're done. @@ -36,8 +36,8 @@ graphs/dashboards. GitLab provides two built-in tools to aid the process of improving performance: -* [Sherlock](doc/development/profiling.md#sherlock) -* [GitLab Performance Monitoring](doc/monitoring/performance/monitoring.md) +* [Sherlock](profiling.md#sherlock) +* [GitLab Performance Monitoring](../monitoring/performance/monitoring.md) GitLab employees can use GitLab.com's performance monitoring systems located at <http://performance.gitlab.net>, this requires you to log in using your @@ -254,5 +254,4 @@ referencing an object directly may even slow code down. [#15607]: https://gitlab.com/gitlab-org/gitlab-ce/issues/15607 [yorickpeterse]: https://gitlab.com/u/yorickpeterse -[joshfng]: https://gitlab.com/u/joshfng [anti-pattern]: https://en.wikipedia.org/wiki/Anti-pattern diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md index 8852dbcb19e..a7175f3f87e 100644 --- a/doc/development/rake_tasks.md +++ b/doc/development/rake_tasks.md @@ -14,11 +14,33 @@ Note: `db:setup` calls `db:seed` but this does nothing. ## Run tests -This runs all test suites present in GitLab. +In order to run the test you can use the following commands: +- `rake spinach` to run the spinach suite +- `rake spec` to run the rspec suite +- `rake teaspoon` to run the teaspoon test suite +- `rake gitlab:test` to run all the tests -``` -bundle exec rake test -``` +Note: Both `rake spinach` and `rake spec` takes significant time to pass. +Instead of running full test suite locally you can save a lot of time by running +a single test or directory related to your changes. After you submit merge request +CI will run full test suite for you. Green CI status in the merge request means +full test suite is passed. + +Note: You can't run `rspec .` since this will try to run all the `_spec.rb` +files it can find, also the ones in `/tmp` + +To run a single test file you can use: + +- `bundle exec rspec spec/controllers/commit_controller_spec.rb` for a rspec test +- `bundle exec spinach features/project/issues/milestones.feature` for a spinach test + +To run several tests inside one directory: + +- `bundle exec rspec spec/requests/api/` for the rspec tests if you want to test API only +- `bundle exec spinach features/profile/` for the spinach tests if you want to test only profile pages + +If you want to use [Spring](https://github.com/rails/spring) set +`ENABLE_SPRING=1` in your environment. ## Generate searchable docs for source code diff --git a/doc/development/ui_guide.md b/doc/development/ui_guide.md index 65252288019..2d1d504202c 100644 --- a/doc/development/ui_guide.md +++ b/doc/development/ui_guide.md @@ -15,11 +15,14 @@ repository and maintained by GitLab UX designers. ## Navigation GitLab's layout contains 2 sections: the left sidebar and the content. The left sidebar contains a static navigation menu. -This menu will be visible regardless of what page you visit. The left sidebar also contains the GitLab logo -and the current user's profile picture. The content section contains a header and the content itself. -The header describes the current GitLab page and what navigation is -available to user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example when user visits one of the -project pages the header will contain a project name and navigation for that project. When the user visits a group page it will contain a group name and navigation related to this group. +This menu will be visible regardless of what page you visit. +The content section contains a header and the content itself. The header describes the current GitLab page and what navigation is +available to the user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example, when the user visits one of the +project pages the header will contain the project's name and navigation for that project. When the user visits a group page it will contain the group's name and navigation related to this group. + +You can see a visual representation of the navigation in GitLab in the GitLab Product Map, which is located in the [Design Repository](gitlab-map-graffle) +along with [PDF](gitlab-map-pdf) and [PNG](gitlab-map-png) exports. + ### Adding new tab to header navigation @@ -47,6 +50,42 @@ information from database or file system * `rss` for rss/atom feed * `plus` for link or dropdown that lead to page where you create new object (For example new issue page) +### SVGs + +When exporting SVGs, be sure to follow the following guidelines: + +1. Convert all strokes to outlines. +2. Use pathfinder tools to combine overlapping paths and create compound paths. +3. SVGs that are limited to one color should be exported without a fill color so the color can be set using CSS. +4. Ensure that exported SVGs have been run through an [SVG cleaner](https://github.com/RazrFalcon/SVGCleaner) to remove unused elements and attributes. + +You can open your svg in a text editor to ensure that it is clean. +Incorrect files will look like this: + +```xml +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg width="16px" height="17px" viewBox="0 0 16 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 3.7.2 (28276) - http://www.bohemiancoding.com/sketch --> + <title>Group</title> + <desc>Created with Sketch.</desc> + <defs></defs> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Group" fill="#7E7C7C"> + <path d="M15.1111,1 L0.8891,1 C0.3981,1 0.0001,1.446 0.0001,1.996 L0.0001,15.945 C0.0001,16.495 0.3981,16.941 0.8891,16.941 L15.1111,16.941 C15.6021,16.941 16.0001,16.495 16.0001,15.945 L16.0001,1.996 C16.0001,1.446 15.6021,1 15.1111,1 L15.1111,1 L15.1111,1 Z M14.0001,6.0002 L14.0001,14.949 L2.0001,14.949 L2.0001,6.0002 L14.0001,6.0002 Z M14.0001,4.0002 L14.0001,2.993 L2.0001,2.993 L2.0001,4.0002 L14.0001,4.0002 Z" id="Combined-Shape"></path> + <polygon id="Fill-11" points="3 2.0002 5 2.0002 5 0.0002 3 0.0002"></polygon> + <polygon id="Fill-16" points="11 2.0002 13 2.0002 13 0.0002 11 0.0002"></polygon> + <path d="M5.37709616,11.5511984 L6.92309616,12.7821984 C7.35112915,13.123019 7.97359761,13.0565604 8.32002627,12.6330535 L10.7740263,9.63305349 C11.1237073,9.20557058 11.0606364,8.57555475 10.6331535,8.22587373 C10.2056706,7.87619272 9.57565475,7.93926361 9.22597373,8.36674651 L6.77197373,11.3667465 L8.16890384,11.2176016 L6.62290384,9.98660159 C6.19085236,9.6425813 5.56172188,9.71394467 5.21770159,10.1459962 C4.8736813,10.5780476 4.94504467,11.2071781 5.37709616,11.5511984 L5.37709616,11.5511984 Z" id="Stroke-21"></path> + </g> + </g> +</svg> +``` + +Correct file will look like this: + +```xml +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 17" enable-background="new 0 0 16 17"><path d="m15.1 1h-2.1v-1h-2v1h-6v-1h-2v1h-2.1c-.5 0-.9.5-.9 1v14c0 .6.4 1 .9 1h14.2c.5 0 .9-.4.9-1v-14c0-.5-.4-1-.9-1m-1.1 14h-12v-9h12v9m0-11h-12v-1h12v1"/><path d="m5.4 11.6l1.5 1.2c.4.3 1.1.3 1.4-.1l2.5-3c.3-.4.3-1.1-.1-1.4-.5-.4-1.1-.3-1.5.1l-1.8 2.2-.8-.6c-.4-.3-1.1-.3-1.4.2-.3.4-.3 1 .2 1.4"/></svg> +``` + ## Buttons @@ -63,3 +102,6 @@ Do not use both green and blue button in one form. display counts in the UI. [number_with_delimiter]: http://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_with_delimiter +[gitlab-map-graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/master/production/resources/gitlab-map.graffle +[gitlab-map-pdf]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.pdf +[gitlab-map-png]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png
\ No newline at end of file diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md new file mode 100644 index 00000000000..2574c2c0472 --- /dev/null +++ b/doc/development/what_requires_downtime.md @@ -0,0 +1,161 @@ +# What requires downtime? + +When working with a database certain operations can be performed without taking +GitLab offline, others do require a downtime period. This guide describes +various operations and their impact. + +## Adding Columns + +On PostgreSQL you can safely add a new column to an existing table as long as it +does **not** have a default value. For example, this query would not require +downtime: + +```sql +ALTER TABLE projects ADD COLUMN random_value int; +``` + +Add a column _with_ a default however does require downtime. For example, +consider this query: + +```sql +ALTER TABLE projects ADD COLUMN random_value int DEFAULT 42; +``` + +This requires updating every single row in the `projects` table so that +`random_value` is set to `42` by default. This requires updating all rows and +indexes in a table. This in turn acquires enough locks on the table for it to +effectively block any other queries. + +As of MySQL 5.6 adding a column to a table is still quite an expensive +operation, even when using `ALGORITHM=INPLACE` and `LOCK=NONE`. This means +downtime _may_ be required when modifying large tables as otherwise the +operation could potentially take hours to complete. + +Adding a column with a default value _can_ be done without requiring downtime +when using the migration helper method +`Gitlab::Database::MigrationHelpers#add_column_with_default`. This method works +similar to `add_column` except it updates existing rows in batches without +blocking access to the table being modified. See ["Adding Columns With Default +Values"](migration_style_guide.html#adding-columns-with-default-values) for more +information on how to use this method. + +## Dropping Columns + +On PostgreSQL you can safely remove an existing column without the need for +downtime. When you drop a column in PostgreSQL it's not immediately removed, +instead it is simply disabled. The data is removed on the next vacuum run. + +On MySQL this operation requires downtime. + +While database wise dropping a column may be fine on PostgreSQL this operation +still requires downtime because the application code may still be using the +column that was removed. For example, consider the following migration: + +```ruby +class MyMigration < ActiveRecord::Migration + def change + remove_column :projects, :dummy + end +end +``` + +Now imagine that the GitLab instance is running and actively uses the `dummy` +column. If we were to run the migration this would result in the GitLab instance +producing errors whenever it tries to use the `dummy` column. + +As a result of the above downtime _is_ required when removing a column, even +when using PostgreSQL. + +## Changing Column Constraints + +Generally changing column constraints requires checking all rows in the table to +see if they meet the new constraint, unless a constraint is _removed_. For +example, changing a column that previously allowed NULL values to not allow NULL +values requires the database to verify all existing rows. + +The specific behaviour varies a bit between databases but in general the safest +approach is to assume changing constraints requires downtime. + +## Changing Column Types + +This operation requires downtime. + +## Adding Indexes + +Adding indexes is an expensive process that blocks INSERT and UPDATE queries for +the duration. When using PostgreSQL one can work arounds this by using the +`CONCURRENTLY` option: + +```sql +CREATE INDEX CONCURRENTLY index_name ON projects (column_name); +``` + +Migrations can take advantage of this by using the method +`add_concurrent_index`. For example: + +```ruby +class MyMigration < ActiveRecord::Migration + def change + add_concurrent_index :projects, :column_name + end +end +``` + +When running this on PostgreSQL the `CONCURRENTLY` option mentioned above is +used. On MySQL this method produces a regular `CREATE INDEX` query. + +MySQL doesn't really have a workaround for this. Supposedly it _can_ create +indexes without the need for downtime but only for variable width columns. The +details on this are a bit sketchy. Since it's better to be safe than sorry one +should assume that adding indexes requires downtime on MySQL. + +## Dropping Indexes + +Dropping an index does not require downtime on both PostgreSQL and MySQL. + +## Adding Tables + +This operation is safe as there's no code using the table just yet. + +## Dropping Tables + +This operation requires downtime as application code may still be using the +table. + +## Adding Foreign Keys + +Adding foreign keys acquires an exclusive lock on both the source and target +tables in PostgreSQL. This requires downtime as otherwise the entire application +grinds to a halt for the duration of the operation. + +On MySQL this operation also requires downtime _unless_ foreign key checks are +disabled. Because this means checks aren't enforced this is not ideal, as such +one should assume MySQL also requires downtime. + +## Removing Foreign Keys + +This operation should not require downtime on both PostgreSQL and MySQL. + +## Updating Data + +Updating data should generally be safe. The exception to this is data that's +being migrated from one version to another while the application still produces +data in the old version. + +For example, imagine the application writes the string `'dog'` to a column but +it really is meant to write `'cat'` instead. One might think that the following +migration is all that is needed to solve this problem: + +```ruby +class MyMigration < ActiveRecord::Migration + def up + execute("UPDATE some_table SET column = 'cat' WHERE column = 'dog';") + end +end +``` + +Unfortunately this is not enough. Because the application is still running and +using the old value this may result in the table still containing rows where +`column` is set to `dog`, even after the migration finished. + +In these cases downtime _is_ required, even for rarely updated tables. diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md index 3aa83975ace..d7e3aa35bdd 100644 --- a/doc/gitlab-basics/README.md +++ b/doc/gitlab-basics/README.md @@ -2,14 +2,14 @@ Step-by-step guides on the basics of working with Git and GitLab. +- [Command line basics](command-line-commands.md) - [Start using Git on the command line](start-using-git.md) - [Create and add your SSH Keys](create-your-ssh-keys.md) -- [Command Line basics](command-line-commands.md) - [Create a project](create-project.md) - [Create a group](create-group.md) - [Create a branch](create-branch.md) - [Fork a project](fork-project.md) - [Add a file](add-file.md) - [Add an image](add-image.md) -- [Create a Merge Request](add-merge-request.md) -- [Create an Issue](create-issue.md) +- [Create an issue](create-issue.md) +- [Create a merge request](add-merge-request.md) diff --git a/doc/gitlab-basics/add-file.md b/doc/gitlab-basics/add-file.md index 57136ac5c39..e9fbcbc23a9 100644 --- a/doc/gitlab-basics/add-file.md +++ b/doc/gitlab-basics/add-file.md @@ -1,31 +1,5 @@ # How to add a file -You can create a file in your [shell](command-line-commands.md) or in GitLab. - -To create a file in GitLab, sign in to GitLab. - -Select a project on the right side of your screen: - - - -It's a good idea to [create a branch](create-branch.md), but it's not necessary. - -Go to the directory where you'd like to add the file and click on the "+" sign next to the name of the project and directory: - - - -Name your file (you can't add spaces, so you can use hyphens or underscores). Don't forget to include the markup language you'd like to use : - - - -Add all the information that you'd like to include in your file: - - - -Add a commit message based on what you just added and then click on "commit changes": - - - -### Note -Besides its regular files, every directory needs a README.md or README.html file which works like an index, telling -what the directory is about. It's the first document you'll find when you open a directory. +You can create a file in your [terminal](command-line-commands.md) and push +to GitLab or you can use the +[web interface](../user/project/repository/web_editor.md#create-a-file). diff --git a/doc/gitlab-basics/add-merge-request.md b/doc/gitlab-basics/add-merge-request.md index 236b4248ea2..bf01fe51dc3 100644 --- a/doc/gitlab-basics/add-merge-request.md +++ b/doc/gitlab-basics/add-merge-request.md @@ -1,42 +1,33 @@ # How to create a merge request -Merge Requests are useful to integrate separate changes that you've made to a project, on different branches. +Merge requests are useful to integrate separate changes that you've made to a +project, on different branches. This is a brief guide on how to create a merge +request. For more information, check the +[merge requests documentation](../user/project/merge_requests.md). -To create a new Merge Request, sign in to GitLab. +--- -Go to the project where you'd like to merge your changes: +1. Before you start, you should have already [created a branch](create-branch.md) + and [pushed your changes](basic-git-commands.md) to GitLab. - +1. You can then go to the project where you'd like to merge your changes and + click on the **Merge requests** tab. -Click on "Merge Requests" on the left side of your screen: +  - +1. Click on **New merge request** on the right side of the screen. -Click on "+ new Merge Request" on the right side of the screen: +  - +1. Select a source branch and click on the **Compare branches and continue** button. -Select a source branch or branch: +  - +1. At a minimum, add a title and a description to your merge request. Optionally, + select a user to review your merge request and to accept or close it. You may + also select a milestone and labels. -Click on the "compare branches" button: +  - - -Add a title and a description to your Merge Request: - - - -Select a user to review your Merge Request and to accept or close it. You may also select milestones and labels (they are optional). Then click on the "submit new Merge Request" button: - - - -Your Merge Request will be ready to be approved and published. - -### Note - -After you created a new branch, you'll immediately find a "create a Merge Request" button at the top of your screen. -You may automatically create a Merge Request from your recently created branch when clicking on this button: - - +1. When ready, click on the **Submit merge request** button. Your merge request + will be ready to be approved and published. diff --git a/doc/gitlab-basics/basicsimages/add_new_merge_request.png b/doc/gitlab-basics/basicsimages/add_new_merge_request.png Binary files differdeleted file mode 100644 index e60992c4c6a..00000000000 --- a/doc/gitlab-basics/basicsimages/add_new_merge_request.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/add_sshkey.png b/doc/gitlab-basics/basicsimages/add_sshkey.png Binary files differdeleted file mode 100644 index 89c86018629..00000000000 --- a/doc/gitlab-basics/basicsimages/add_sshkey.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/branch_info.png b/doc/gitlab-basics/basicsimages/branch_info.png Binary files differdeleted file mode 100644 index 2264f3c5bf2..00000000000 --- a/doc/gitlab-basics/basicsimages/branch_info.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/branch_name.png b/doc/gitlab-basics/basicsimages/branch_name.png Binary files differdeleted file mode 100644 index 75fe8313611..00000000000 --- a/doc/gitlab-basics/basicsimages/branch_name.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/branches.png b/doc/gitlab-basics/basicsimages/branches.png Binary files differdeleted file mode 100644 index 8621bc05776..00000000000 --- a/doc/gitlab-basics/basicsimages/branches.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/button-create-mr.png b/doc/gitlab-basics/basicsimages/button-create-mr.png Binary files differdeleted file mode 100644 index b52ab148839..00000000000 --- a/doc/gitlab-basics/basicsimages/button-create-mr.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/click-on-new-group.png b/doc/gitlab-basics/basicsimages/click-on-new-group.png Binary files differdeleted file mode 100644 index 6450deec6fc..00000000000 --- a/doc/gitlab-basics/basicsimages/click-on-new-group.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/commit_changes.png b/doc/gitlab-basics/basicsimages/commit_changes.png Binary files differdeleted file mode 100644 index a88809c5a3f..00000000000 --- a/doc/gitlab-basics/basicsimages/commit_changes.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/commit_message.png b/doc/gitlab-basics/basicsimages/commit_message.png Binary files differdeleted file mode 100644 index 4abe4517f98..00000000000 --- a/doc/gitlab-basics/basicsimages/commit_message.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/commits.png b/doc/gitlab-basics/basicsimages/commits.png Binary files differdeleted file mode 100644 index 2bfcaf75f01..00000000000 --- a/doc/gitlab-basics/basicsimages/commits.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/compare_branches.png b/doc/gitlab-basics/basicsimages/compare_branches.png Binary files differdeleted file mode 100644 index 8a18453dd05..00000000000 --- a/doc/gitlab-basics/basicsimages/compare_branches.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/create_file.png b/doc/gitlab-basics/basicsimages/create_file.png Binary files differdeleted file mode 100644 index 5ebe1b227dd..00000000000 --- a/doc/gitlab-basics/basicsimages/create_file.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/create_group.png b/doc/gitlab-basics/basicsimages/create_group.png Binary files differdeleted file mode 100644 index 7ecc3baa990..00000000000 --- a/doc/gitlab-basics/basicsimages/create_group.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/edit_file.png b/doc/gitlab-basics/basicsimages/edit_file.png Binary files differdeleted file mode 100644 index 9d3e817d036..00000000000 --- a/doc/gitlab-basics/basicsimages/edit_file.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/file_located.png b/doc/gitlab-basics/basicsimages/file_located.png Binary files differdeleted file mode 100644 index e357cb5c6ab..00000000000 --- a/doc/gitlab-basics/basicsimages/file_located.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/file_name.png b/doc/gitlab-basics/basicsimages/file_name.png Binary files differdeleted file mode 100644 index 01639c77d0d..00000000000 --- a/doc/gitlab-basics/basicsimages/file_name.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/find_file.png b/doc/gitlab-basics/basicsimages/find_file.png Binary files differdeleted file mode 100644 index 6f26d26ae18..00000000000 --- a/doc/gitlab-basics/basicsimages/find_file.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/find_group.png b/doc/gitlab-basics/basicsimages/find_group.png Binary files differdeleted file mode 100644 index 1211510aae9..00000000000 --- a/doc/gitlab-basics/basicsimages/find_group.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/fork.png b/doc/gitlab-basics/basicsimages/fork.png Binary files differdeleted file mode 100644 index 13ff8345616..00000000000 --- a/doc/gitlab-basics/basicsimages/fork.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/group_info.png b/doc/gitlab-basics/basicsimages/group_info.png Binary files differdeleted file mode 100644 index 2507d6c295b..00000000000 --- a/doc/gitlab-basics/basicsimages/group_info.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/groups.png b/doc/gitlab-basics/basicsimages/groups.png Binary files differdeleted file mode 100644 index ef3dca60cc8..00000000000 --- a/doc/gitlab-basics/basicsimages/groups.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/https.png b/doc/gitlab-basics/basicsimages/https.png Binary files differdeleted file mode 100644 index e74dbc13f9a..00000000000 --- a/doc/gitlab-basics/basicsimages/https.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/image_file.png b/doc/gitlab-basics/basicsimages/image_file.png Binary files differdeleted file mode 100644 index 7f304b8e1f2..00000000000 --- a/doc/gitlab-basics/basicsimages/image_file.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/issue_title.png b/doc/gitlab-basics/basicsimages/issue_title.png Binary files differdeleted file mode 100644 index 60a6f7973be..00000000000 --- a/doc/gitlab-basics/basicsimages/issue_title.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/issues.png b/doc/gitlab-basics/basicsimages/issues.png Binary files differdeleted file mode 100644 index 14e9cdb64e1..00000000000 --- a/doc/gitlab-basics/basicsimages/issues.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/key.png b/doc/gitlab-basics/basicsimages/key.png Binary files differdeleted file mode 100644 index 04400173ce8..00000000000 --- a/doc/gitlab-basics/basicsimages/key.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/merge_requests.png b/doc/gitlab-basics/basicsimages/merge_requests.png Binary files differdeleted file mode 100644 index 570164df18b..00000000000 --- a/doc/gitlab-basics/basicsimages/merge_requests.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/new_issue.png b/doc/gitlab-basics/basicsimages/new_issue.png Binary files differdeleted file mode 100644 index 94e7503dd8b..00000000000 --- a/doc/gitlab-basics/basicsimages/new_issue.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/new_merge_request.png b/doc/gitlab-basics/basicsimages/new_merge_request.png Binary files differdeleted file mode 100644 index 842f5ebed74..00000000000 --- a/doc/gitlab-basics/basicsimages/new_merge_request.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/new_project.png b/doc/gitlab-basics/basicsimages/new_project.png Binary files differdeleted file mode 100644 index 421e8bc247b..00000000000 --- a/doc/gitlab-basics/basicsimages/new_project.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/newbranch.png b/doc/gitlab-basics/basicsimages/newbranch.png Binary files differdeleted file mode 100644 index d5fcf33c4ea..00000000000 --- a/doc/gitlab-basics/basicsimages/newbranch.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/paste_sshkey.png b/doc/gitlab-basics/basicsimages/paste_sshkey.png Binary files differdeleted file mode 100644 index 578ebee4440..00000000000 --- a/doc/gitlab-basics/basicsimages/paste_sshkey.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/profile_settings.png b/doc/gitlab-basics/basicsimages/profile_settings.png Binary files differdeleted file mode 100644 index cb3f79f1879..00000000000 --- a/doc/gitlab-basics/basicsimages/profile_settings.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/project_info.png b/doc/gitlab-basics/basicsimages/project_info.png Binary files differdeleted file mode 100644 index e1adb8d48c2..00000000000 --- a/doc/gitlab-basics/basicsimages/project_info.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/select-group.png b/doc/gitlab-basics/basicsimages/select-group.png Binary files differdeleted file mode 100644 index 33b978dd899..00000000000 --- a/doc/gitlab-basics/basicsimages/select-group.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/select-group2.png b/doc/gitlab-basics/basicsimages/select-group2.png Binary files differdeleted file mode 100644 index aee22c638db..00000000000 --- a/doc/gitlab-basics/basicsimages/select-group2.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/select_branch.png b/doc/gitlab-basics/basicsimages/select_branch.png Binary files differdeleted file mode 100644 index f72a3ffb57f..00000000000 --- a/doc/gitlab-basics/basicsimages/select_branch.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/select_project.png b/doc/gitlab-basics/basicsimages/select_project.png Binary files differdeleted file mode 100644 index 3bb832ea8d0..00000000000 --- a/doc/gitlab-basics/basicsimages/select_project.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/settings.png b/doc/gitlab-basics/basicsimages/settings.png Binary files differdeleted file mode 100644 index 78637013d9b..00000000000 --- a/doc/gitlab-basics/basicsimages/settings.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/shh_keys.png b/doc/gitlab-basics/basicsimages/shh_keys.png Binary files differdeleted file mode 100644 index c87f11a9d3d..00000000000 --- a/doc/gitlab-basics/basicsimages/shh_keys.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/submit_new_issue.png b/doc/gitlab-basics/basicsimages/submit_new_issue.png Binary files differdeleted file mode 100644 index 78b854c8903..00000000000 --- a/doc/gitlab-basics/basicsimages/submit_new_issue.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/title_description_mr.png b/doc/gitlab-basics/basicsimages/title_description_mr.png Binary files differdeleted file mode 100644 index c31d61ec336..00000000000 --- a/doc/gitlab-basics/basicsimages/title_description_mr.png +++ /dev/null diff --git a/doc/gitlab-basics/basicsimages/white_space.png b/doc/gitlab-basics/basicsimages/white_space.png Binary files differdeleted file mode 100644 index eaa969bdcf4..00000000000 --- a/doc/gitlab-basics/basicsimages/white_space.png +++ /dev/null diff --git a/doc/gitlab-basics/command-line-commands.md b/doc/gitlab-basics/command-line-commands.md index addd3b6b6eb..3b075ff5fc0 100644 --- a/doc/gitlab-basics/command-line-commands.md +++ b/doc/gitlab-basics/command-line-commands.md @@ -4,18 +4,21 @@ In Git, when you copy a project you say you "clone" it. To work on a git project locally (from your own computer), you will need to clone it. To do this, sign in to GitLab. -When you are on your Dashboard, click on the project that you'd like to clone, which you'll find at the right side of your screen. +When you are on your Dashboard, click on the project that you'd like to clone. +To work in the project, you can copy a link to the Git repository through a SSH +or a HTTPS protocol. SSH is easier to use after it's been +[setup](create-your-ssh-keys.md). While you are at the **Project** tab, select +HTTPS or SSH from the dropdown menu and copy the link using the 'Copy to clipboard' +button (you'll have to paste it on your shell in the next step). - - -To work in the project, you can copy a link to the Git repository through a SSH or a HTTPS protocol. SSH is easier to use after it's been [setup](create-your-ssh-keys.md). When you're in the project, click on the HTTPS or SSH button at the right side of your screen. Then copy the link (you'll have to paste it on your shell in the next step). - - + ## On the command line ### Clone your project + Go to your computer's shell and type the following command: + ``` git clone PASTE HTTPS OR SSH HERE ``` @@ -23,26 +26,31 @@ git clone PASTE HTTPS OR SSH HERE A clone of the project will be created in your computer. ### Go into a project, directory or file to work in it + ``` cd NAME-OF-PROJECT-OR-FILE ``` ### Go back one directory or file + ``` cd ../ ``` ### View what’s in the directory that you are in + ``` ls ``` ### Create a directory + ``` mkdir NAME-OF-YOUR-DIRECTORY ``` ### Create a README.md or file in directory + ``` touch README.md nano README.md @@ -53,27 +61,33 @@ nano README.md ``` ### Remove a file + ``` rm NAME-OF-FILE ``` ### Remove a directory and all of its contents + ``` rm -rf NAME-OF-DIRECTORY ``` ### View history in the command line + ``` history ``` ### Carry out commands for which the account you are using lacks authority + You will be asked for an administrator’s password. + ``` sudo ``` ### Tell where you are + ``` pwd ``` diff --git a/doc/gitlab-basics/create-branch.md b/doc/gitlab-basics/create-branch.md index 7556b0f663e..ad94f0dad29 100644 --- a/doc/gitlab-basics/create-branch.md +++ b/doc/gitlab-basics/create-branch.md @@ -2,38 +2,11 @@ A branch is an independent line of development. -New commits are recorded in the history for the current branch, which results in taking the source from someone’s repository (the place where the history of your work is stored) at certain point in time, and apply your own changes to it in the history of the project. - -To add changes to your GitLab project, you should create a branch. You can do it in your [shell](basic-git-commands.md) or in GitLab. - -To create a new branch in GitLab, sign in and then select a project on the right side of your screen: - - - -Click on "commits" on the menu on the left side of your screen: - - - -Click on the "branches" tab: - - - -Click on the "new branch" button on the right side of the screen: - - - -Fill out the information required: - -1. Add a name for your new branch (you can't add spaces, so you can use hyphens or underscores) - -1. On the "create from" space, add the the name of the branch you want to branch off from - -1. Click on the button "create branch" - - - -### Note: - -You will be able to find and select the name of your branch in the white box next to a project's name: - - +New commits are recorded in the history for the current branch, which results +in taking the source from someone’s repository (the place where the history of +your work is stored) at certain point in time, and apply your own changes to it +in the history of the project. + +To add changes to your GitLab project, you should create a branch. You can do +it in your [terminal](basic-git-commands.md) or by +[using the web interface](../user/project/repository/web_editor.md#create-a-new-branch). diff --git a/doc/gitlab-basics/create-group.md b/doc/gitlab-basics/create-group.md index f80ae62e442..64274ccd5eb 100644 --- a/doc/gitlab-basics/create-group.md +++ b/doc/gitlab-basics/create-group.md @@ -1,43 +1,48 @@ # How to create a group in GitLab -## Create a group - Your projects in GitLab can be organized in 2 different ways: -under your own namespace for single projects, such as ´your-name/project-1'; or under groups. -If you organize your projects under a group, it works like a folder. You can manage your group members' permissions and access to the projects. - -To create a group, follow the instructions below: +under your own namespace for single projects, such as `your-name/project-1` or +under groups. -Sign in to [GitLab.com](https://gitlab.com). +If you organize your projects under a group, it works like a folder. You can +manage your group members' permissions and access to the projects. -When you are on your Dashboard, click on "Groups" on the left menu of your screen: +--- - +To create a group: -Click on "New group" on the top right side of your screen: +1. Expand the left sidebar by clicking the three bars at the upper left corner + and then navigate to **Groups**. - +  -Fill out the information required: +1. Once in your groups dashboard, click on **New group**. -1. Add a group path or group name (you can't add spaces, so you can use hyphens or underscores) +  -1. Add details or a group description +1. Fill out the needed information: -1. You can choose a group avatar if you'd like + 1. Set the "Group path" which will be the namespace under which your projects + will be hosted (path can contain only letters, digits, underscores, dashes + and dots; it cannot start with dashes or end in dot). + 1. Optionally, you can add a description so that others can briefly understand + what this group is about. + 1. Optionally, choose and avatar for your project. + 1. Choose the [visibility level](../public_access/public_access.md). -1. Click on "create group" +1. Finally, click the **Create group** button. - - -## Add a project to a group +## Add a new project to a group There are 2 different ways to add a new project to a group: -* Select a group and then click on "New project" on the right side of your screen. Then you can [create a project](create-project.md) +- Select a group and then click on the **New project** button. + +  - + You can then continue on [creating a project](create-project.md). -* When you are [creating a project](create-project.md), click on "create a group" on the bottom right side of your screen +- While you are [creating a project](create-project.md), select a group namespace + you've already created from the dropdown menu. - +  diff --git a/doc/gitlab-basics/create-issue.md b/doc/gitlab-basics/create-issue.md index 5221d85b661..13e5a738c89 100644 --- a/doc/gitlab-basics/create-issue.md +++ b/doc/gitlab-basics/create-issue.md @@ -1,27 +1,30 @@ # How to create an Issue in GitLab -The Issue Tracker is a good place to add things that need to be improved or solved in a project. +The issue tracker is a good place to add things that need to be improved or +solved in a project. -To create an Issue, sign in to GitLab. +--- -Go to the project where you'd like to create the Issue: +1. Go to the project where you'd like to create the issue and navigate to the + **Issues** tab on top. - +  -Click on "Issues" on the left side of your screen: +1. Click on the **New issue** button on the right side of your screen. - +  -Click on the "+ new issue" button on the right side of your screen: +1. At the very minimum, add a title and a description to your issue. + You may assign it to a user, add a milestone or add labels (all optional). - +  -Add a title and a description to your issue: +1. When ready, click on **Submit issue**. - +--- -You may assign the Issue to a user, add a milestone and add labels (they are all optional). Then click on "submit new issue": - - - -Your Issue will now be added to the Issue Tracker and will be ready to be reviewed. You can comment on it and mention the people involved. You can also link Issues to the Merge Requests where the Issues are solved. To do this, you can use an [Issue closing pattern](http://docs.gitlab.com/ce/customization/issue_closing.html). +Your Issue will now be added to the issue tracker of the project you opened it +at and will be ready to be reviewed. You can comment on it and mention the +people involved. You can also link issues to the merge requests where the issues +are solved. To do this, you can use an +[issue closing pattern](../user/project/issues/automatic_issue_closing.md). diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md index f737dffc024..3f45a631b3a 100644 --- a/doc/gitlab-basics/create-project.md +++ b/doc/gitlab-basics/create-project.md @@ -1,21 +1,24 @@ # How to create a project in GitLab -To create a new project, sign in to GitLab. +There are two ways to create a new project in GitLab. -Go to your Dashboard and click on "new project" on the right side of your screen. +1. While in your dashboard, you can create a new project using the **New project** + green button or you can use the cross icon in the upper right corner next to + your avatar which is always visible. - +  -Fill out the required information: +1. From there you can see several options. -1. Project path or the name of your project (you can't add spaces, so you can use hyphens or underscores) +  -1. Your project's description +1. Fill out the information: -1. Select a [visibility level](https://gitlab.com/help/public_access/public_access) + 1. "Project name" is the name of your project (you can't use spaces, but you + can use hyphens or underscores). + 1. The "Project description" is optional and will be shown in your project's + dashboard so others can briefly understand what your project is about. + 1. Select a [visibility level](../public_access/public_access.md). + 1. You can also [import your existing projects](../workflow/importing/README.md). -1. You can also [import your existing projects](http://docs.gitlab.com/ce/workflow/importing/README.html) - -1. Click on "create project" - -! +1. Finally, click **Create project**. diff --git a/doc/gitlab-basics/create-your-ssh-keys.md b/doc/gitlab-basics/create-your-ssh-keys.md index f31c353f2cf..b6ebe374de3 100644 --- a/doc/gitlab-basics/create-your-ssh-keys.md +++ b/doc/gitlab-basics/create-your-ssh-keys.md @@ -1,33 +1,37 @@ # How to create your SSH Keys -You need to connect your computer to your GitLab account through SSH Keys. They are unique for every computer that you link your GitLab account with. +1. The first thing you need to do is go to your [command line](start-using-git.md) + and follow the [instructions](../ssh/README.md) to generate your SSH key pair. -## Generate your SSH Key +1. Once you do that, login to GitLab with your credentials. +1. On the upper right corner, click on your avatar and go to your **Profile settings**. -Create an account on GitLab. Sign up and check your email for your confirmation link. +  -After you confirm, go to GitLab and sign in to your account. +1. Navigate to the **SSH keys** tab. -## Add your SSH Key +  -On the left side menu, click on "profile settings" and then click on "SSH Keys": +3. Paste your **public** key that you generated in the first step in the 'Key' + box. - +  -Then click on the green button "Add SSH Key": +1. Optionally, give it a descriptive title so that you can recognize it in the + event you add multiple keys. - +  -There, you should paste the SSH Key that your command line will generate for you. Below you'll find the steps to generate it: +1. Finally, click on **Add key** to add it to GitLab. You will be able to see + its fingerprint, its title and creation date. - +  -## To generate an SSH Key on your command line -Go to your [command line](start-using-git.md) and follow the [instructions](../ssh/README.md) to generate it. +>**Note:** +Once you add a key, you cannot edit it, only remove it. In case the paste +didn't work, you will have to remove the offending key and re-add it. -Copy the SSH Key that your command line created and paste it on the "Key" box on the GitLab page. The title will be added automatically. +--- - - -Now, you'll be able to use Git over SSH, instead of Git over HTTP. +Congratulations! You are now ready to use Git over SSH, instead of Git over HTTP! diff --git a/doc/gitlab-basics/fork-project.md b/doc/gitlab-basics/fork-project.md index 5f8b81ea919..6c232fe6086 100644 --- a/doc/gitlab-basics/fork-project.md +++ b/doc/gitlab-basics/fork-project.md @@ -1,19 +1,20 @@ # How to fork a project -A fork is a copy of an original repository that you can put somewhere else -or where you can experiment and apply changes that you can later decide if +A fork is a copy of an original repository that you can put in another namespace +where you can experiment and apply changes that you can later decide if publishing or not, without affecting your original project. It takes just a few steps to fork a project in GitLab. -Sign in to GitLab. +1. Go to a project's dashboard under the **Project** tab and click on the + **Fork** button. -Select a project on the right side of your screen: +  - +1. You will be asked where to fork the repository. Click on the user or group + to where you'd like to add the forked project. -Click on the "fork" button on the right side of your screen: +  - - -Click on the user or group to where you'd like to add the forked project. +1. After a few moments, depending on the repository's size, the forking will + complete. diff --git a/doc/gitlab-basics/img/create_new_group_info.png b/doc/gitlab-basics/img/create_new_group_info.png Binary files differnew file mode 100644 index 00000000000..c8eddfd1bbb --- /dev/null +++ b/doc/gitlab-basics/img/create_new_group_info.png diff --git a/doc/gitlab-basics/img/create_new_group_sidebar.png b/doc/gitlab-basics/img/create_new_group_sidebar.png Binary files differnew file mode 100644 index 00000000000..28017ee02e0 --- /dev/null +++ b/doc/gitlab-basics/img/create_new_group_sidebar.png diff --git a/doc/gitlab-basics/img/create_new_project_button.png b/doc/gitlab-basics/img/create_new_project_button.png Binary files differnew file mode 100644 index 00000000000..e7c794d943f --- /dev/null +++ b/doc/gitlab-basics/img/create_new_project_button.png diff --git a/doc/gitlab-basics/img/create_new_project_from_group.png b/doc/gitlab-basics/img/create_new_project_from_group.png Binary files differnew file mode 100644 index 00000000000..6d41d17f9ca --- /dev/null +++ b/doc/gitlab-basics/img/create_new_project_from_group.png diff --git a/doc/gitlab-basics/img/create_new_project_info.png b/doc/gitlab-basics/img/create_new_project_info.png Binary files differnew file mode 100644 index 00000000000..16d56f0707f --- /dev/null +++ b/doc/gitlab-basics/img/create_new_project_info.png diff --git a/doc/gitlab-basics/img/fork_choose_namespace.png b/doc/gitlab-basics/img/fork_choose_namespace.png Binary files differnew file mode 100644 index 00000000000..82c9c3bd39e --- /dev/null +++ b/doc/gitlab-basics/img/fork_choose_namespace.png diff --git a/doc/gitlab-basics/img/fork_new.png b/doc/gitlab-basics/img/fork_new.png Binary files differnew file mode 100644 index 00000000000..41885223286 --- /dev/null +++ b/doc/gitlab-basics/img/fork_new.png diff --git a/doc/gitlab-basics/img/merge_request_new.png b/doc/gitlab-basics/img/merge_request_new.png Binary files differnew file mode 100644 index 00000000000..0aba5743f01 --- /dev/null +++ b/doc/gitlab-basics/img/merge_request_new.png diff --git a/doc/gitlab-basics/img/merge_request_page.png b/doc/gitlab-basics/img/merge_request_page.png Binary files differnew file mode 100644 index 00000000000..68c3bbf9444 --- /dev/null +++ b/doc/gitlab-basics/img/merge_request_page.png diff --git a/doc/gitlab-basics/img/merge_request_select_branch.png b/doc/gitlab-basics/img/merge_request_select_branch.png Binary files differnew file mode 100644 index 00000000000..516436ff6cc --- /dev/null +++ b/doc/gitlab-basics/img/merge_request_select_branch.png diff --git a/doc/gitlab-basics/img/new_issue_button.png b/doc/gitlab-basics/img/new_issue_button.png Binary files differnew file mode 100644 index 00000000000..46b626bed65 --- /dev/null +++ b/doc/gitlab-basics/img/new_issue_button.png diff --git a/doc/gitlab-basics/img/new_issue_page.png b/doc/gitlab-basics/img/new_issue_page.png Binary files differnew file mode 100644 index 00000000000..843504130b7 --- /dev/null +++ b/doc/gitlab-basics/img/new_issue_page.png diff --git a/doc/gitlab-basics/img/profile_settings.png b/doc/gitlab-basics/img/profile_settings.png Binary files differnew file mode 100644 index 00000000000..f0abd478849 --- /dev/null +++ b/doc/gitlab-basics/img/profile_settings.png diff --git a/doc/gitlab-basics/img/profile_settings_ssh_keys.png b/doc/gitlab-basics/img/profile_settings_ssh_keys.png Binary files differnew file mode 100644 index 00000000000..2c9a42fe10c --- /dev/null +++ b/doc/gitlab-basics/img/profile_settings_ssh_keys.png diff --git a/doc/gitlab-basics/img/profile_settings_ssh_keys_paste_pub.png b/doc/gitlab-basics/img/profile_settings_ssh_keys_paste_pub.png Binary files differnew file mode 100644 index 00000000000..cd7add6937f --- /dev/null +++ b/doc/gitlab-basics/img/profile_settings_ssh_keys_paste_pub.png diff --git a/doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.png b/doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.png Binary files differnew file mode 100644 index 00000000000..095beb02be8 --- /dev/null +++ b/doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.png diff --git a/doc/gitlab-basics/img/profile_settings_ssh_keys_title.png b/doc/gitlab-basics/img/profile_settings_ssh_keys_title.png Binary files differnew file mode 100644 index 00000000000..4b998a7f948 --- /dev/null +++ b/doc/gitlab-basics/img/profile_settings_ssh_keys_title.png diff --git a/doc/gitlab-basics/img/project_clone_url.png b/doc/gitlab-basics/img/project_clone_url.png Binary files differnew file mode 100644 index 00000000000..eed430e1036 --- /dev/null +++ b/doc/gitlab-basics/img/project_clone_url.png diff --git a/doc/gitlab-basics/img/project_navbar.png b/doc/gitlab-basics/img/project_navbar.png Binary files differnew file mode 100644 index 00000000000..97cf3cd9702 --- /dev/null +++ b/doc/gitlab-basics/img/project_navbar.png diff --git a/doc/gitlab-basics/basicsimages/public_file_link.png b/doc/gitlab-basics/img/public_file_link.png Binary files differindex f60df6807f4..f60df6807f4 100644 --- a/doc/gitlab-basics/basicsimages/public_file_link.png +++ b/doc/gitlab-basics/img/public_file_link.png diff --git a/doc/gitlab-basics/img/select_group_dropdown.png b/doc/gitlab-basics/img/select_group_dropdown.png Binary files differnew file mode 100644 index 00000000000..7d8b89c2df9 --- /dev/null +++ b/doc/gitlab-basics/img/select_group_dropdown.png diff --git a/doc/gitlab-basics/start-using-git.md b/doc/gitlab-basics/start-using-git.md index b61f436c1a4..42cd8bb3e48 100644 --- a/doc/gitlab-basics/start-using-git.md +++ b/doc/gitlab-basics/start-using-git.md @@ -1,11 +1,10 @@ # Start using Git on the command line -If you want to start using a Git and GitLab, make sure that you have created an -account on GitLab. +If you want to start using Git and GitLab, make sure that you have created and/or signed into an account on GitLab. ## Open a shell -Depending on your operating system, find the shell of your preference. Here are some suggestions. +Depending on your operating system, you will need to use a shell of your preference. Here are some suggestions: - [Terminal](http://blog.teamtreehouse.com/introduction-to-the-mac-os-x-command-line) on Mac OSX @@ -22,19 +21,19 @@ Type the following command and then press enter: git --version ``` -You should receive a message that will tell you which Git version you have in your computer. If you don’t receive a "Git version" message, it means that you need to [download Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). +You should receive a message that will tell you which Git version you have on your computer. If you don’t receive a "Git version" message, it means that you need to [download Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). If Git doesn't automatically download, there's an option on the website to [download manually](https://git-scm.com/downloads). Then follow the steps on the installation window. -After you finished installing, open a new shell and type "git --version" again to verify that it was correctly installed. +After you are finished installing, open a new shell and type "git --version" again to verify that it was correctly installed. ## Add your Git username and set your email -It is important because every Git commit that you create will use this information. +It is important to configure your Git username and email address as every Git commit will use this information to identify you as the author. On your shell, type the following command to add your username: ``` -git config --global user.name ADD YOUR USERNAME +git config --global user.name "YOUR_USERNAME" ``` Then verify that you have the correct username: @@ -44,7 +43,7 @@ git config --global user.name To set your email address, type the following command: ``` -git config --global user.email ADD YOUR EMAIL +git config --global user.email "your_email_address@example.com" ``` To verify that you entered your email correctly, type: @@ -52,7 +51,7 @@ To verify that you entered your email correctly, type: git config --global user.email ``` -You'll need to do this only once because you are using the "--global" option. It tells Git to always use this information for anything you do on that system. If you want to override this with a different username or email address for specific projects, you can run the command without the "--global" option when you’re in that project. +You'll need to do this only once as you are using the `--global` option. It tells Git to always use this information for anything you do on that system. If you want to override this with a different username or email address for specific projects, you can run the command without the `--global` option when you’re in that project. ## Check your information @@ -76,7 +75,7 @@ git pull REMOTE NAME-OF-BRANCH -u (REMOTE: origin) (NAME-OF-BRANCH: could be "master" or an existing branch) ### Create a branch -Spaces won't be recognized, so you need to use a hyphen or underscore. +Spaces won't be recognized, so you will need to use a hyphen or underscore. ``` git checkout -b NAME-OF-BRANCH ``` @@ -127,4 +126,3 @@ You need to be in the master branch. git checkout master git merge NAME-OF-BRANCH ``` - diff --git a/doc/incoming_email/README.md b/doc/incoming_email/README.md index 5a9a1582877..db0f03f2c98 100644 --- a/doc/incoming_email/README.md +++ b/doc/incoming_email/README.md @@ -1,302 +1 @@ -# Reply by email - -GitLab can be set up to allow users to comment on issues and merge requests by -replying to notification emails. - -## Requirement - -Reply by email requires an IMAP-enabled email account. GitLab allows you to use -three strategies for this feature: -- using email sub-addressing -- using a dedicated email address -- using a catch-all mailbox - -### Email sub-addressing - -**If your provider or server supports email sub-addressing, we recommend using it.** - -[Sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) is -a feature where any email to `user+some_arbitrary_tag@example.com` will end up -in the mailbox for `user@example.com`, and is supported by providers such as -Gmail, Google Apps, Yahoo! Mail, Outlook.com and iCloud, as well as the Postfix -mail server which you can run on-premises. - -### Dedicated email address - -This solution is really simple to set up: you just have to create an email -address dedicated to receive your users' replies to GitLab notifications. - -### Catch-all mailbox - -A [catch-all mailbox](https://en.wikipedia.org/wiki/Catch-all) for a domain will -"catch all" the emails addressed to the domain that do not exist in the mail -server. - -## How it works? - -### 1. GitLab sends a notification email - -When GitLab sends a notification and Reply by email is enabled, the `Reply-To` -header is set to the address defined in your GitLab configuration, with the -`%{key}` placeholder (if present) replaced by a specific "reply key". In -addition, this "reply key" is also added to the `References` header. - -### 2. You reply to the notification email - -When you reply to the notification email, your email client will: - -- send the email to the `Reply-To` address it got from the notification email -- set the `In-Reply-To` header to the value of the `Message-ID` header from the - notification email -- set the `References` header to the value of the `Message-ID` plus the value of - the notification email's `References` header. - -### 3. GitLab receives your reply to the notification email - -When GitLab receives your reply, it will look for the "reply key" in the -following headers, in this order: - -1. the `To` header -1. the `References` header - -If it finds a reply key, it will be able to leave your reply as a comment on -the entity the notification was about (issue, merge request, commit...). - -For more details about the `Message-ID`, `In-Reply-To`, and `References headers`, -please consult [RFC 5322](https://tools.ietf.org/html/rfc5322#section-3.6.4). - -## Set it up - -If you want to use Gmail / Google Apps with Reply by email, make sure you have -[IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018) -and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255). - -To set up a basic Postfix mail server with IMAP access on Ubuntu, follow -[these instructions](./postfix.md). - -### Omnibus package installations - -1. Find the `incoming_email` section in `/etc/gitlab/gitlab.rb`, enable the - feature and fill in the details for your specific IMAP server and email account: - - ```ruby - # Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com - gitlab_rails['incoming_email_enabled'] = true - - # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. - # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). - gitlab_rails['incoming_email_address'] = "incoming+%{key}@gitlab.example.com" - - # Email account username - # With third party providers, this is usually the full email address. - # With self-hosted email servers, this is usually the user part of the email address. - gitlab_rails['incoming_email_email'] = "incoming" - # Email account password - gitlab_rails['incoming_email_password'] = "[REDACTED]" - - # IMAP server host - gitlab_rails['incoming_email_host'] = "gitlab.example.com" - # IMAP server port - gitlab_rails['incoming_email_port'] = 143 - # Whether the IMAP server uses SSL - gitlab_rails['incoming_email_ssl'] = false - # Whether the IMAP server uses StartTLS - gitlab_rails['incoming_email_start_tls'] = false - - # The mailbox where incoming mail will end up. Usually "inbox". - gitlab_rails['incoming_email_mailbox_name'] = "inbox" - ``` - - ```ruby - # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com - gitlab_rails['incoming_email_enabled'] = true - - # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. - # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). - gitlab_rails['incoming_email_address'] = "gitlab-incoming+%{key}@gmail.com" - - # Email account username - # With third party providers, this is usually the full email address. - # With self-hosted email servers, this is usually the user part of the email address. - gitlab_rails['incoming_email_email'] = "gitlab-incoming@gmail.com" - # Email account password - gitlab_rails['incoming_email_password'] = "[REDACTED]" - - # IMAP server host - gitlab_rails['incoming_email_host'] = "imap.gmail.com" - # IMAP server port - gitlab_rails['incoming_email_port'] = 993 - # Whether the IMAP server uses SSL - gitlab_rails['incoming_email_ssl'] = true - # Whether the IMAP server uses StartTLS - gitlab_rails['incoming_email_start_tls'] = false - - # The mailbox where incoming mail will end up. Usually "inbox". - gitlab_rails['incoming_email_mailbox_name'] = "inbox" - ``` - -1. Reconfigure GitLab and restart mailroom for the changes to take effect: - - ```sh - sudo gitlab-ctl reconfigure - sudo gitlab-ctl restart mailroom - ``` - -1. Verify that everything is configured correctly: - - ```sh - sudo gitlab-rake gitlab:incoming_email:check - ``` - -1. Reply by email should now be working. - -### Installations from source - -1. Go to the GitLab installation directory: - - ```sh - cd /home/git/gitlab - ``` - -1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature - and fill in the details for your specific IMAP server and email account: - - ```sh - sudo editor config/gitlab.yml - ``` - - ```yaml - # Configuration for Postfix mail server, assumes mailbox incoming@gitlab.example.com - incoming_email: - enabled: true - - # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. - # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). - address: "incoming+%{key}@gitlab.example.com" - - # Email account username - # With third party providers, this is usually the full email address. - # With self-hosted email servers, this is usually the user part of the email address. - user: "incoming" - # Email account password - password: "[REDACTED]" - - # IMAP server host - host: "gitlab.example.com" - # IMAP server port - port: 143 - # Whether the IMAP server uses SSL - ssl: false - # Whether the IMAP server uses StartTLS - start_tls: false - - # The mailbox where incoming mail will end up. Usually "inbox". - mailbox: "inbox" - ``` - - ```yaml - # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com - incoming_email: - enabled: true - - # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. - # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). - address: "gitlab-incoming+%{key}@gmail.com" - - # Email account username - # With third party providers, this is usually the full email address. - # With self-hosted email servers, this is usually the user part of the email address. - user: "gitlab-incoming@gmail.com" - # Email account password - password: "[REDACTED]" - - # IMAP server host - host: "imap.gmail.com" - # IMAP server port - port: 993 - # Whether the IMAP server uses SSL - ssl: true - # Whether the IMAP server uses StartTLS - start_tls: false - - # The mailbox where incoming mail will end up. Usually "inbox". - mailbox: "inbox" - ``` - -1. Enable `mail_room` in the init script at `/etc/default/gitlab`: - - ```sh - sudo mkdir -p /etc/default - echo 'mail_room_enabled=true' | sudo tee -a /etc/default/gitlab - ``` - -1. Restart GitLab: - - ```sh - sudo service gitlab restart - ``` - -1. Verify that everything is configured correctly: - - ```sh - sudo -u git -H bundle exec rake gitlab:incoming_email:check RAILS_ENV=production - ``` - -1. Reply by email should now be working. - -### Development - -1. Go to the GitLab installation directory. - -1. Find the `incoming_email` section in `config/gitlab.yml`, enable the feature and fill in the details for your specific IMAP server and email account: - - ```yaml - # Configuration for Gmail / Google Apps, assumes mailbox gitlab-incoming@gmail.com - incoming_email: - enabled: true - - # The email address including the `%{key}` placeholder that will be replaced to reference the item being replied to. - # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`). - address: "gitlab-incoming+%{key}@gmail.com" - - # Email account username - # With third party providers, this is usually the full email address. - # With self-hosted email servers, this is usually the user part of the email address. - user: "gitlab-incoming@gmail.com" - # Email account password - password: "[REDACTED]" - - # IMAP server host - host: "imap.gmail.com" - # IMAP server port - port: 993 - # Whether the IMAP server uses SSL - ssl: true - # Whether the IMAP server uses StartTLS - start_tls: false - - # The mailbox where incoming mail will end up. Usually "inbox". - mailbox: "inbox" - ``` - - As mentioned, the part after `+` is ignored, and this will end up in the mailbox for `gitlab-incoming@gmail.com`. - -1. Uncomment the `mail_room` line in your `Procfile`: - - ```yaml - mail_room: bundle exec mail_room -q -c config/mail_room.yml - ``` - -1. Restart GitLab: - - ```sh - bundle exec foreman start - ``` - -1. Verify that everything is configured correctly: - - ```sh - bundle exec rake gitlab:incoming_email:check RAILS_ENV=development - ``` - -1. Reply by email should now be working. +This document was moved to [administration/reply_by_email](../administration/reply_by_email.md). diff --git a/doc/incoming_email/postfix.md b/doc/incoming_email/postfix.md index 787d21f7f8f..90833238ac5 100644 --- a/doc/incoming_email/postfix.md +++ b/doc/incoming_email/postfix.md @@ -1,321 +1 @@ -# Set up Postfix for Reply by email - -This document will take you through the steps of setting up a basic Postfix mail server with IMAP authentication on Ubuntu, to be used with Reply by email. - -The instructions make the assumption that you will be using the email address `incoming@gitlab.example.com`, that is, username `incoming` on host `gitlab.example.com`. Don't forget to change it to your actual host when executing the example code snippets. - -## Configure your server firewall - -1. Open up port 25 on your server so that people can send email into the server over SMTP. -2. If the mail server is different from the server running GitLab, open up port 143 on your server so that GitLab can read email from the server over IMAP. - -## Install packages - -1. Install the `postfix` package if it is not installed already: - - ```sh - sudo apt-get install postfix - ``` - - When asked about the environment, select 'Internet Site'. When asked to confirm the hostname, make sure it matches `gitlab.example.com`. - -1. Install the `mailutils` package. - - ```sh - sudo apt-get install mailutils - ``` - -## Create user - -1. Create a user for incoming email. - - ```sh - sudo useradd -m -s /bin/bash incoming - ``` - -1. Set a password for this user. - - ```sh - sudo passwd incoming - ``` - - Be sure not to forget this, you'll need it later. - -## Test the out-of-the-box setup - -1. Connect to the local SMTP server: - - ```sh - telnet localhost 25 - ``` - - You should see a prompt like this: - - ```sh - Trying 127.0.0.1... - Connected to localhost. - Escape character is '^]'. - 220 gitlab.example.com ESMTP Postfix (Ubuntu) - ``` - - If you get a `Connection refused` error instead, verify that `postfix` is running: - - ```sh - sudo postfix status - ``` - - If it is not, start it: - - ```sh - sudo postfix start - ``` - -1. Send the new `incoming` user a dummy email to test SMTP, by entering the following into the SMTP prompt: - - ``` - ehlo localhost - mail from: root@localhost - rcpt to: incoming@localhost - data - Subject: Re: Some issue - - Sounds good! - . - quit - ``` - - _**Note:** The `.` is a literal period on its own line._ - - _**Note:** If you receive an error after entering `rcpt to: incoming@localhost` - then your Postfix `my_network` configuration is not correct. The error will - say 'Temporary lookup failure'. See - [Configure Postfix to receive email from the Internet](#configure-postfix-to-receive-email-from-the-internet)._ - -1. Check if the `incoming` user received the email: - - ```sh - su - incoming - mail - ``` - - You should see output like this: - - ``` - "/var/mail/incoming": 1 message 1 unread - >U 1 root@localhost 59/2842 Re: Some issue - ``` - - Quit the mail app: - - ```sh - q - ``` - -1. Log out of the `incoming` account and go back to being `root`: - - ```sh - logout - ``` - -## Configure Postfix to use Maildir-style mailboxes - -Courier, which we will install later to add IMAP authentication, requires mailboxes to have the Maildir format, rather than mbox. - -1. Configure Postfix to use Maildir-style mailboxes: - - ```sh - sudo postconf -e "home_mailbox = Maildir/" - ``` - -1. Restart Postfix: - - ```sh - sudo /etc/init.d/postfix restart - ``` - -1. Test the new setup: - - 1. Follow steps 1 and 2 of _[Test the out-of-the-box setup](#test-the-out-of-the-box-setup)_. - 1. Check if the `incoming` user received the email: - - ```sh - su - incoming - MAIL=/home/incoming/Maildir - mail - ``` - - You should see output like this: - - ``` - "/home/incoming/Maildir": 1 message 1 unread - >U 1 root@localhost 59/2842 Re: Some issue - ``` - - Quit the mail app: - - ```sh - q - ``` - - _**Note:** If `mail` returns an error `Maildir: Is a directory` then your - version of `mail` doesn't support Maildir style mailboxes. Install - `heirloom-mailx` by running `sudo apt-get install heirloom-mailx`. Then, - try the above steps again, substituting `heirloom-mailx` for the `mail` - command._ - -1. Log out of the `incoming` account and go back to being `root`: - - ```sh - logout - ``` - -## Install the Courier IMAP server - -1. Install the `courier-imap` package: - - ```sh - sudo apt-get install courier-imap - ``` - -## Configure Postfix to receive email from the internet - -1. Let Postfix know about the domains that it should consider local: - - ```sh - sudo postconf -e "mydestination = gitlab.example.com, localhost.localdomain, localhost" - ``` - -1. Let Postfix know about the IPs that it should consider part of the LAN: - - We'll assume `192.168.1.0/24` is your local LAN. You can safely skip this step if you don't have other machines in the same local network. - - ```sh - sudo postconf -e "mynetworks = 127.0.0.0/8, 192.168.1.0/24" - ``` - -1. Configure Postfix to receive mail on all interfaces, which includes the internet: - - ```sh - sudo postconf -e "inet_interfaces = all" - ``` - -1. Configure Postfix to use the `+` delimiter for sub-addressing: - - ```sh - sudo postconf -e "recipient_delimiter = +" - ``` - -1. Restart Postfix: - - ```sh - sudo service postfix restart - ``` - -## Test the final setup - -1. Test SMTP under the new setup: - - 1. Connect to the SMTP server: - - ```sh - telnet gitlab.example.com 25 - ``` - - You should see a prompt like this: - - ```sh - Trying 123.123.123.123... - Connected to gitlab.example.com. - Escape character is '^]'. - 220 gitlab.example.com ESMTP Postfix (Ubuntu) - ``` - - If you get a `Connection refused` error instead, make sure your firewall is setup to allow inbound traffic on port 25. - - 1. Send the `incoming` user a dummy email to test SMTP, by entering the following into the SMTP prompt: - - ``` - ehlo gitlab.example.com - mail from: root@gitlab.example.com - rcpt to: incoming@gitlab.example.com - data - Subject: Re: Some issue - - Sounds good! - . - quit - ``` - - (Note: The `.` is a literal period on its own line) - - 1. Check if the `incoming` user received the email: - - ```sh - su - incoming - MAIL=/home/incoming/Maildir - mail - ``` - - You should see output like this: - - ``` - "/home/incoming/Maildir": 1 message 1 unread - >U 1 root@gitlab.example.com 59/2842 Re: Some issue - ``` - - Quit the mail app: - - ```sh - q - ``` - - 1. Log out of the `incoming` account and go back to being `root`: - - ```sh - logout - ``` - -1. Test IMAP under the new setup: - - 1. Connect to the IMAP server: - - ```sh - telnet gitlab.example.com 143 - ``` - - You should see a prompt like this: - - ```sh - Trying 123.123.123.123... - Connected to mail.example.gitlab.com. - Escape character is '^]'. - - OK [CAPABILITY IMAP4rev1 UIDPLUS CHILDREN NAMESPACE THREAD=ORDEREDSUBJECT THREAD=REFERENCES SORT QUOTA IDLE ACL ACL2=UNION] Courier-IMAP ready. Copyright 1998-2011 Double Precision, Inc. See COPYING for distribution information. - ``` - - 1. Sign in as the `incoming` user to test IMAP, by entering the following into the IMAP prompt: - - ``` - a login incoming PASSWORD - ``` - - Replace PASSWORD with the password you set on the `incoming` user earlier. - - You should see output like this: - - ``` - a OK LOGIN Ok. - ``` - - 1. Disconnect from the IMAP server: - - ```sh - a logout - ``` - -## Done! - -If all the tests were successful, Postfix is all set up and ready to receive email! Continue with the [Reply by email](./README.md) guide to configure GitLab. - ---------- - -_This document was adapted from https://help.ubuntu.com/community/PostfixBasicSetupHowto, by contributors to the Ubuntu documentation wiki._ +This document was moved to [administration/reply_by_email_postfix_setup](../administration/reply_by_email_postfix_setup.md). diff --git a/doc/install/installation.md b/doc/install/installation.md index af8e31a705b..1fa8678223a 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -89,7 +89,7 @@ Is the system packaged Git too old? Remove it and compile from source. # Download and compile from source cd /tmp - curl -O --progress https://www.kernel.org/pub/software/scm/git/git-2.7.4.tar.gz + curl --remote-name --progress https://www.kernel.org/pub/software/scm/git/git-2.7.4.tar.gz echo '7104c4f5d948a75b499a954524cb281fe30c6649d8abe20982936f75ec1f275b git-2.7.4.tar.gz' | shasum -a256 -c - && tar -xzf git-2.7.4.tar.gz cd git-2.7.4/ ./configure @@ -108,8 +108,7 @@ Then select 'Internet Site' and press enter to confirm the hostname. ## 2. Ruby -_**Note:** The current supported Ruby version is 2.1.x. Ruby 2.2 and 2.3 are -currently not supported._ +**Note:** The current supported Ruby versions are 2.1.x and 2.3.x. 2.3.x is preferred, and support for 2.1.x will be dropped in the future. The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab in production, frequently leads to hard to diagnose problems. For example, @@ -124,9 +123,9 @@ Remove the old Ruby 1.8 if present: Download Ruby and compile it: mkdir /tmp/ruby && cd /tmp/ruby - curl -O --progress https://cache.ruby-lang.org/pub/ruby/2.1/ruby-2.1.8.tar.gz - echo 'c7e50159357afd87b13dc5eaf4ac486a70011149 ruby-2.1.8.tar.gz' | shasum -c - && tar xzf ruby-2.1.8.tar.gz - cd ruby-2.1.8 + curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz + echo 'c39b4001f7acb4e334cb60a0f4df72d434bef711 ruby-2.3.1.tar.gz' | shasum -c - && tar xzf ruby-2.3.1.tar.gz + cd ruby-2.3.1 ./configure --disable-install-rdoc make sudo make install @@ -143,7 +142,7 @@ gitlab-workhorse we need a Go compiler. The instructions below assume you use 64-bit Linux. You can find downloads for other platforms at the [Go download page](https://golang.org/dl). - curl -O --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz + curl --remote-name --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ @@ -269,9 +268,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-11-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-13-stable gitlab -**Note:** You can change `8-11-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `8-13-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It @@ -332,6 +331,9 @@ sudo usermod -aG redis git # Disable 'git gc --auto' because GitLab already runs 'git gc' when needed sudo -u git -H git config --global gc.auto 0 + # Enable packfile bitmaps + sudo -u git -H git config --global repack.writeBitmaps true + # Configure Redis connection settings sudo -u git -H cp config/resque.yml.example config/resque.yml @@ -379,7 +381,7 @@ sudo usermod -aG redis git GitLab Shell is an SSH access and repository management software developed specially for GitLab. # Run the installation task for gitlab-shell (replace `REDIS_URL` if needed): - sudo -u git -H bundle exec rake gitlab:shell:install REDIS_URL=unix:/var/run/redis/redis.sock RAILS_ENV=production + sudo -u git -H bundle exec rake gitlab:shell:install REDIS_URL=unix:/var/run/redis/redis.sock RAILS_ENV=production SKIP_STORAGE_VALIDATION=true # By default, the gitlab-shell config is generated from your main GitLab config. # You can review (and modify) the gitlab-shell config as follows: @@ -398,7 +400,7 @@ If you are not using Linux you may have to run `gmake` instead of cd /home/git sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-workhorse.git cd gitlab-workhorse - sudo -u git -H git checkout v0.7.8 + sudo -u git -H git checkout v0.8.4 sudo -u git -H make ### Initialize Database and Activate Advanced Features @@ -561,7 +563,7 @@ Using a self-signed certificate is discouraged but if you must use it follow the ### Enable Reply by email -See the ["Reply by email" documentation](../incoming_email/README.md) for more information on how to set this up. +See the ["Reply by email" documentation](../administration/reply_by_email.md) for more information on how to set this up. ### LDAP Authentication @@ -588,15 +590,17 @@ for the changes to take effect. ### Custom Redis Connection -If you'd like Resque to connect to a Redis server on a non-standard port or on a different host, you can configure its connection string via the `config/resque.yml` file. +If you'd like to connect to a Redis server on a non-standard port or on a different host, you can configure its connection string via the `config/resque.yml` file. # example - production: redis://redis.example.tld:6379 + production: + url: redis://redis.example.tld:6379 If you want to connect the Redis server via socket, then use the "unix:" URL scheme and the path to the Redis socket file in the `config/resque.yml` file. # example - production: unix:/path/to/redis/socket + production: + url: unix:/path/to/redis/socket ### Custom SSH Connection diff --git a/doc/install/requirements.md b/doc/install/requirements.md index a65ac8a5f79..766a7119943 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -32,7 +32,7 @@ Please consider using a virtual machine to run GitLab. ## Ruby versions -GitLab requires Ruby (MRI) 2.1.x and currently does not work with versions 2.2 or 2.3. +GitLab requires Ruby (MRI) 2.3. Support for Ruby versions below 2.3 (2.1, 2.2) will stop with GitLab 8.13. You will have to use the standard MRI implementation of Ruby. We love [JRuby](http://jruby.org/) and [Rubinius](http://rubini.us/) but GitLab @@ -63,30 +63,30 @@ If you have enough RAM memory and a recent CPU the speed of GitLab is mainly lim ### Memory -You need at least 2GB of addressable memory (RAM + swap) to install and use GitLab! +You need at least 4GB of addressable memory (RAM + swap) to install and use GitLab! The operating system and any other running applications will also be using memory -so keep in mind that you need at least 2GB available before running GitLab. With +so keep in mind that you need at least 4GB available before running GitLab. With less memory GitLab will give strange errors during the reconfigure run and 500 errors during usage. -- 512MB RAM + 1.5GB of swap is the absolute minimum but we strongly **advise against** this amount of memory. See the unicorn worker section below for more advice. -- 1GB RAM + 1GB swap supports up to 100 users but it will be very slow -- **2GB RAM** is the **recommended** memory size for all installations and supports up to 100 users -- 4GB RAM supports up to 1,000 users -- 8GB RAM supports up to 2,000 users -- 16GB RAM supports up to 4,000 users -- 32GB RAM supports up to 8,000 users -- 64GB RAM supports up to 16,000 users -- 128GB RAM supports up to 32,000 users +- 1GB RAM + 3GB of swap is the absolute minimum but we strongly **advise against** this amount of memory. See the unicorn worker section below for more advice. +- 2GB RAM + 2GB swap supports up to 100 users but it will be very slow +- **4GB RAM** is the **recommended** memory size for all installations and supports up to 100 users +- 8GB RAM supports up to 1,000 users +- 16GB RAM supports up to 2,000 users +- 32GB RAM supports up to 4,000 users +- 64GB RAM supports up to 8,000 users +- 128GB RAM supports up to 16,000 users +- 256GB RAM supports up to 32,000 users - More users? Run it on [multiple application servers](https://about.gitlab.com/high-availability/) -We recommend having at least 1GB of swap on your server, even if you currently have +We recommend having at least 2GB of swap on your server, even if you currently have enough available RAM. Having swap will help reduce the chance of errors occurring if your available memory changes. Notice: The 25 workers of Sidekiq will show up as separate processes in your process overview (such as top or htop) but they share the same RAM allocation since Sidekiq is a multithreaded application. Please see the section below about Unicorn workers for information about many you need of those. -## Gitlab Runner +## GitLab Runner We strongly advise against installing GitLab Runner on the same machine you plan to install GitLab on. Depending on how you decide to configure GitLab Runner and @@ -113,10 +113,8 @@ It's possible to increase the amount of unicorn workers and this will usually he For most instances we recommend using: CPU cores + 1 = unicorn workers. So for a machine with 2 cores, 3 unicorn workers is ideal. -For all machines that have 1GB and up we recommend a minimum of three unicorn workers. -If you have a 512MB machine with a magnetic (non-SSD) swap drive we recommend to configure only one Unicorn worker to prevent excessive swapping. -With one Unicorn worker only git over ssh access will work because the git over HTTP access requires two running workers (one worker to receive the user request and one worker for the authorization check). -If you have a 512MB machine with a SSD drive you can use two Unicorn workers, this will allow HTTP access although it will be slow due to swapping. +For all machines that have 2GB and up we recommend a minimum of three unicorn workers. +If you have a 1GB machine we recommend to configure only two Unicorn workers to prevent excessive swapping. To change the Unicorn workers when you have the Omnibus package please see [the Unicorn settings in the Omnibus GitLab documentation](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/settings/unicorn.md#unicorn-settings). diff --git a/doc/integration/README.md b/doc/integration/README.md index ddbd570ac6c..c2fd299db07 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -15,6 +15,7 @@ See the documentation below for details on how to configure these services. - [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages - [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 GitLab Enterprise Edition contains [advanced Jenkins support][jenkins]. diff --git a/doc/integration/akismet.md b/doc/integration/akismet.md index c222d21612f..a6436b5f926 100644 --- a/doc/integration/akismet.md +++ b/doc/integration/akismet.md @@ -22,14 +22,37 @@ To use Akismet: 2. Sign-in or create a new account. -3. Click on "Show" to reveal the API key. +3. Click on **Show** to reveal the API key. 4. Go to Applications Settings on Admin Area (`admin/application_settings`) -5. Check the `Enable Akismet` checkbox +5. Check the **Enable Akismet** checkbox 6. Fill in the API key from step 3. 7. Save the configuration.  + + +## Training + +> *Note:* Training the Akismet filter is only available in 8.11 and above. + +As a way to better recognize between spam and ham, you can train the Akismet +filter whenever there is a false positive or false negative. + +When an entry is recognized as spam, it is rejected and added to the Spam Logs. +From here you can review if they are really spam. If one of them is not really +spam, you can use the **Submit as ham** button to tell Akismet that it falsely +recognized an entry as spam. + + + +If an entry that is actually spam was not recognized as such, you will be able +to also submit this to Akismet. The **Submit as spam** button will only appear +to admin users. + + + +Training Akismet will help it to recognize spam more accurately in the future. diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md index 63432b04432..556d71b8b76 100644 --- a/doc/integration/bitbucket.md +++ b/doc/integration/bitbucket.md @@ -1,111 +1,164 @@ -# Integrate your server with Bitbucket +# Integrate your GitLab server with Bitbucket -Import projects from Bitbucket and login to your GitLab instance with your Bitbucket account. +Import projects from Bitbucket.org and login to your GitLab instance with your +Bitbucket.org account. -To enable the Bitbucket OmniAuth provider you must register your application with Bitbucket. -Bitbucket will generate an application ID and secret key for you to use. +## Overview -1. Sign in to Bitbucket. +You can set up Bitbucket.org as an OAuth provider so that you can use your +credentials to authenticate into GitLab or import your projects from +Bitbucket.org. -1. Navigate to your individual user settings or a team's settings, depending on how you want the application registered. It does not matter if the application is registered as an individual or a team - that is entirely up to you. +- To use Bitbucket.org as an OmniAuth provider, follow the [Bitbucket OmniAuth + provider](#bitbucket-omniauth-provider) section. +- To import projects from Bitbucket, follow both the + [Bitbucket OmniAuth provider](#bitbucket-omniauth-provider) and + [Bitbucket project import](#bitbucket-project-import) sections. -1. Select "OAuth" in the left menu. +## Bitbucket OmniAuth provider -1. Select "Add consumer". +> **Note:** +Make sure to first follow the [Initial OmniAuth configuration][init-oauth] +before proceeding with setting up the Bitbucket integration. -1. Provide the required details. - - Name: This can be anything. Consider something like "\<Organization\>'s GitLab" or "\<Your Name\>'s GitLab" or something else descriptive. - - Application description: Fill this in if you wish. - - URL: The URL to your GitLab installation. 'https://gitlab.company.com' -1. Select "Save". +To enable the Bitbucket OmniAuth provider you must register your application +with Bitbucket.org. Bitbucket will generate an application ID and secret key for +you to use. -1. You should now see a Key and Secret in the list of OAuth customers. - Keep this page open as you continue configuration. +1. Sign in to [Bitbucket.org](https://bitbucket.org). +1. Navigate to your individual user settings (**Bitbucket settings**) or a team's + settings (**Manage team**), depending on how you want the application registered. + It does not matter if the application is registered as an individual or a + team, that is entirely up to you. +1. Select **OAuth** in the left menu under "Access Management". +1. Select **Add consumer**. +1. Provide the required details: -1. On your GitLab server, open the configuration file. + | Item | Description | + | :--- | :---------- | + | **Name** | This can be anything. Consider something like `<Organization>'s GitLab` or `<Your Name>'s GitLab` or something else descriptive. | + | **Application description** | Fill this in if you wish. | + | **Callback URL** | Leave blank. | + | **URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com`. | - For omnibus package: + And grant at least the following permissions: - ```sh - sudo editor /etc/gitlab/gitlab.rb + ``` + Account: Email + Repositories: Read, Admin ``` - For installations from source: + >**Note:** + It may seem a little odd to giving GitLab admin permissions to repositories, + but this is needed in order for GitLab to be able to clone the repositories. - ```sh - cd /home/git/gitlab +  + +1. Select **Save**. +1. Select your newly created OAuth consumer and you should now see a Key and + Secret in the list of OAuth customers. Keep this page open as you continue + the configuration. + +  + +1. On your GitLab server, open the configuration file: - sudo -u git -H editor config/gitlab.yml ``` + # For Omnibus packages + sudo editor /etc/gitlab/gitlab.rb -1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) for initial settings. + # For installations from source + sudo -u git -H editor /home/git/gitlab/config/gitlab.yml + ``` -1. Add the provider configuration: +1. Follow the [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) + for initial settings. +1. Add the Bitbucket provider configuration: - For omnibus package: + For Omnibus packages: ```ruby - gitlab_rails['omniauth_providers'] = [ - { - "name" => "bitbucket", - "app_id" => "YOUR_KEY", - "app_secret" => "YOUR_APP_SECRET", - "url" => "https://bitbucket.org/" - } - ] + gitlab_rails['omniauth_providers'] = [ + { + "name" => "bitbucket", + "app_id" => "BITBUCKET_APP_KEY", + "app_secret" => "BITBUCKET_APP_SECRET", + "url" => "https://bitbucket.org/" + } + ] ``` - For installation from source: + For installations from source: - ``` - - { name: 'bitbucket', app_id: 'YOUR_KEY', - app_secret: 'YOUR_APP_SECRET' } + ```yaml + - { name: 'bitbucket', + app_id: 'BITBUCKET_APP_KEY', + app_secret: 'BITBUCKET_APP_SECRET' } ``` -1. Change 'YOUR_APP_ID' to the key from the Bitbucket application page from step 7. + --- -1. Change 'YOUR_APP_SECRET' to the secret from the Bitbucket application page from step 7. + Where `BITBUCKET_APP_KEY` is the Key and `BITBUCKET_APP_SECRET` the Secret + from the Bitbucket application page. 1. Save the configuration file. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. -1. If you're using the omnibus package, reconfigure GitLab (```gitlab-ctl reconfigure```). - -1. Restart GitLab for the changes to take effect. - -On the sign in page there should now be a Bitbucket icon below the regular sign in form. -Click the icon to begin the authentication process. Bitbucket will ask the user to sign in and authorize the GitLab application. -If everything goes well the user will be returned to GitLab and will be signed in. +On the sign in page there should now be a Bitbucket icon below the regular sign +in form. Click the icon to begin the authentication process. Bitbucket will ask +the user to sign in and authorize the GitLab application. If everything goes +well, the user will be returned to GitLab and will be signed in. ## Bitbucket project import -To allow projects to be imported directly into GitLab, Bitbucket requires two extra setup steps compared to GitHub and GitLab.com. +To allow projects to be imported directly into GitLab, Bitbucket requires two +extra setup steps compared to [GitHub](github.md) and [GitLab.com](gitlab.md). -Bitbucket doesn't allow OAuth applications to clone repositories over HTTPS, and instead requires GitLab to use SSH and identify itself using your GitLab server's SSH key. +Bitbucket doesn't allow OAuth applications to clone repositories over HTTPS, and +instead requires GitLab to use SSH and identify itself using your GitLab +server's SSH key. -### Step 1: Public key +To be able to access repositories on Bitbucket, GitLab will automatically +register your public key with Bitbucket as a deploy key for the repositories to +be imported. Your public key needs to be at `~/.ssh/bitbucket_rsa` which +translates to `/var/opt/gitlab/.ssh/bitbucket_rsa` for Omnibus packages and to +`/home/git/.ssh/bitbucket_rsa.pub` for installations from source. -To be able to access repositories on Bitbucket, GitLab will automatically register your public key with Bitbucket as a deploy key for the repositories to be imported. Your public key needs to be at `~/.ssh/bitbucket_rsa.pub`, which will expand to `/home/git/.ssh/bitbucket_rsa.pub` in most configurations. +--- -If you have that file in place, you're all set and should see the "Import projects from Bitbucket" option enabled. If you don't, do the following: +Below are the steps that will allow GitLab to be able to import your projects +from Bitbucket. -1. Create a new SSH key: +1. Make sure you [have enabled the Bitbucket OAuth support](#bitbucket-omniauth-provider). +1. Create a new SSH key with an **empty passphrase**: ```sh sudo -u git -H ssh-keygen ``` - When asked `Enter file in which to save the key` specify the correct path, eg. `/home/git/.ssh/bitbucket_rsa`. - Make sure to use an **empty passphrase**. + When asked to 'Enter file in which to save the key' enter: + `/var/opt/gitlab/.ssh/bitbucket_rsa` for Omnibus packages or + `/home/git/.ssh/bitbucket_rsa` for installations from source. The name is + important so make sure to get it right. -1. Configure SSH client to use your new key: + > **Warning:** + This key must NOT be associated with ANY existing Bitbucket accounts. If it + is, the import will fail with an `Access denied! Please verify you can add + deploy keys to this repository.` error. - Open the SSH configuration file of the git user. +1. Next, you need to to configure the SSH client to use your new key. Open the + SSH configuration file of the `git` user: - ```sh - sudo editor /home/git/.ssh/config + ``` + # For Omnibus packages + sudo editor /var/opt/gitlab/.ssh/config + + # For installations from source + sudo editor /home/git/.ssh/config ``` - Add a host configuration for `bitbucket.org`. +1. Add a host configuration for `bitbucket.org`: ```sh Host bitbucket.org @@ -113,28 +166,46 @@ If you have that file in place, you're all set and should see the "Import projec User git ``` -### Step 2: Known hosts - -To allow GitLab to connect to Bitbucket over SSH, you need to add 'bitbucket.org' to your GitLab server's known SSH hosts. Take the following steps to do so: - -1. Manually connect to 'bitbucket.org' over SSH, while logged in as the `git` account that GitLab will use: +1. Save the file and exit. +1. Manually connect to `bitbucket.org` over SSH, while logged in as the `git` + user that GitLab will use: ```sh sudo -u git -H ssh bitbucket.org ``` -1. Verify the RSA key fingerprint you'll see in the response matches the one in the [Bitbucket documentation](https://confluence.atlassian.com/display/BITBUCKET/Use+the+SSH+protocol+with+Bitbucket#UsetheSSHprotocolwithBitbucket-KnownhostorBitbucket'spublickeyfingerprints) (the specific IP address doesn't matter): + That step is performed because GitLab needs to connect to Bitbucket over SSH, + in order to add `bitbucket.org` to your GitLab server's known SSH hosts. + +1. Verify the RSA key fingerprint you'll see in the response matches the one + in the [Bitbucket documentation][bitbucket-docs] (the specific IP address + doesn't matter): ```sh - The authenticity of host 'bitbucket.org (207.223.240.182)' can't be established. - RSA key fingerprint is 97:8c:1b:f2:6f:14:6b:5c:3b:ec:aa:46:46:74:7c:40. + The authenticity of host 'bitbucket.org (104.192.143.1)' can't be established. + RSA key fingerprint is SHA256:zzXQOXSRBEiUtuE8AikJYKwbHaxvSc0ojez9YXaGp1A. Are you sure you want to continue connecting (yes/no)? ``` -1. If the fingerprint matches, type `yes` to continue connecting and have 'bitbucket.org' be added to your known hosts. +1. If the fingerprint matches, type `yes` to continue connecting and have + `bitbucket.org` be added to your known SSH hosts. After confirming you should + see a permission denied message. If you see an authentication successful + message you have done something wrong. The key you are using has already been + added to a Bitbucket account and will cause the import script to fail. Ensure + the key you are using CANNOT authenticate with Bitbucket. +1. Restart GitLab to allow it to find the new public key. -1. Your GitLab server is now able to connect to Bitbucket over SSH. +Your GitLab server is now able to connect to Bitbucket over SSH. You should be +able to see the "Import projects from Bitbucket" option on the New Project page +enabled. -1. Restart GitLab to allow it to find the new public key. +## Acknowledgemts + +Special thanks to the writer behind the following article: + +- http://stratus3d.com/blog/2015/09/06/migrating-from-bitbucket-to-local-gitlab-server/ -You should now see the "Import projects from Bitbucket" option on the New Project page enabled. +[init-oauth]: omniauth.md#initial-omniauth-configuration +[bitbucket-docs]: https://confluence.atlassian.com/bitbucket/use-the-ssh-protocol-with-bitbucket-cloud-221449711.html#UsetheSSHprotocolwithBitbucketCloud-KnownhostorBitbucket%27spublickeyfingerprints +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/github.md b/doc/integration/github.md index 340c8a55fb3..8a01afd1177 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -16,7 +16,7 @@ GitHub will generate an application ID and secret key for you to use. 1. Select "Register new application". 1. Provide the required details. - - Application name: This can be anything. Consider something like "\<Organization\>'s GitLab" or "\<Your Name\>'s GitLab" or something else descriptive. + - Application name: This can be anything. Consider something like `<Organization>'s GitLab` or `<Your Name>'s GitLab` or something else descriptive. - Homepage URL: The URL to your GitLab installation. 'https://gitlab.company.com' - Application description: Fill this in if you wish. - Authorization callback URL is 'http(s)://${YOUR_DOMAIN}' diff --git a/doc/integration/gitlab.md b/doc/integration/gitlab.md index b215cc7c609..6d8f3912ede 100644 --- a/doc/integration/gitlab.md +++ b/doc/integration/gitlab.md @@ -14,7 +14,7 @@ GitLab.com will generate an application ID and secret key for you to use. 1. Select "New application". 1. Provide the required details. - - Name: This can be anything. Consider something like "\<Organization\>'s GitLab" or "\<Your Name\>'s GitLab" or something else descriptive. + - Name: This can be anything. Consider something like `<Organization>'s GitLab` or `<Your Name>'s GitLab` or something else descriptive. - Redirect URI: ``` diff --git a/doc/integration/img/bitbucket_oauth_keys.png b/doc/integration/img/bitbucket_oauth_keys.png Binary files differnew file mode 100644 index 00000000000..3fb2f7524a3 --- /dev/null +++ b/doc/integration/img/bitbucket_oauth_keys.png diff --git a/doc/integration/img/bitbucket_oauth_settings_page.png b/doc/integration/img/bitbucket_oauth_settings_page.png Binary files differnew file mode 100644 index 00000000000..a3047712d8c --- /dev/null +++ b/doc/integration/img/bitbucket_oauth_settings_page.png diff --git a/doc/integration/img/spam_log.png b/doc/integration/img/spam_log.png Binary files differnew file mode 100644 index 00000000000..8d574448690 --- /dev/null +++ b/doc/integration/img/spam_log.png diff --git a/doc/integration/img/submit_issue.png b/doc/integration/img/submit_issue.png Binary files differnew file mode 100644 index 00000000000..5c7896a7eec --- /dev/null +++ b/doc/integration/img/submit_issue.png diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md index 46b260e7033..8a55fce96fe 100644 --- a/doc/integration/omniauth.md +++ b/doc/integration/omniauth.md @@ -102,8 +102,8 @@ To change these settings: block_auto_created_users: true ``` -Now we can choose one or more of the Supported Providers listed above to continue -the configuration process. +Now we can choose one or more of the [Supported Providers](#supported-providers) +listed above to continue the configuration process. ## Enable OmniAuth for an Existing User diff --git a/doc/integration/twitter.md b/doc/integration/twitter.md index 4769f26b259..abbea09f22f 100644 --- a/doc/integration/twitter.md +++ b/doc/integration/twitter.md @@ -7,7 +7,7 @@ To enable the Twitter OmniAuth provider you must register your application with 1. Select "Create new app" 1. Fill in the application details. - - Name: This can be anything. Consider something like "\<Organization\>'s GitLab" or "\<Your Name\>'s GitLab" or + - Name: This can be anything. Consider something like `<Organization>'s GitLab` or `<Your Name>'s GitLab` or something else descriptive. - Description: Create a description. - Website: The URL to your GitLab installation. 'https://gitlab.example.com' diff --git a/doc/intro/README.md b/doc/intro/README.md index 1850031eb26..1790b2b761f 100644 --- a/doc/intro/README.md +++ b/doc/intro/README.md @@ -22,10 +22,10 @@ Create merge requests and review code. - [Fork a project and contribute to it](../workflow/forking_workflow.md) - [Create a new merge request](../gitlab-basics/add-merge-request.md) -- [Automatically close issues from merge requests](../customization/issue_closing.md) -- [Automatically merge when your builds succeed](../workflow/merge_when_build_succeeds.md) -- [Revert any commit](../workflow/revert_changes.md) -- [Cherry-pick any commit](../workflow/cherry_pick_changes.md) +- [Automatically close issues from merge requests](../user/project/issues/automatic_issue_closing.md) +- [Automatically merge when your builds succeed](../user/project/merge_requests/merge_when_build_succeeds.md) +- [Revert any commit](../user/project/merge_requests/revert_changes.md) +- [Cherry-pick any commit](../user/project/merge_requests/cherry_pick_changes.md) ## Test and Deploy diff --git a/doc/legal/corporate_contributor_license_agreement.md b/doc/legal/corporate_contributor_license_agreement.md index 7b94506c297..7f08188bd65 100644 --- a/doc/legal/corporate_contributor_license_agreement.md +++ b/doc/legal/corporate_contributor_license_agreement.md @@ -6,13 +6,17 @@ You accept and agree to the following terms and conditions for Your present and "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with GitLab B.V.. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - "Contribution" shall mean the code, documentation or other original works of authorship expressly identified in Schedule B, as well as any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to GitLab B.V. for inclusion in, or documentation of, any of the products owned or managed by GitLab B.V. (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to GitLab B.V. or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, GitLab B.V. for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + "Contribution" shall mean the code, documentation or other original works of authorship, including any modifications or additions to an existing work, that is submitted by You to GitLab B.V. for inclusion in, or documentation of, any of the products owned or managed by GitLab B.V. (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to GitLab B.V. or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, GitLab B.V. for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." -2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. +2. Grant of Copyright License. -3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. +Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. -4. You represent that You are legally entitled to grant the above license. You represent further that each employee of the Corporation designated on Schedule A below (or in a subsequent written modification to that Schedule) is authorized to submit Contributions on behalf of the Corporation. +3. Grant of Patent License. + +Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. + +4. You represent that You are legally entitled to grant the above license. You represent further that each of Your employees is authorized to submit Contributions on Your behalf, but excluding employees that are designated in writing by You as "Not authorized to submit Contributions on behalf of [name of Your corporation here]." Such designations of exclusion for unauthorized employees are to be submitted via email to legal@gitlab.com. 5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). @@ -20,6 +24,6 @@ You accept and agree to the following terms and conditions for Your present and 7. Should You wish to submit work that is not Your original creation, You may submit it to GitLab B.V. separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". -8. It is your responsibility to notify GitLab B.V. when any change is required to the list of designated employees authorized to submit Contributions on behalf of the Corporation, or to the Corporation's Point of Contact with GitLab B.V.. +8. It is Your responsibility to notify GitLab.com when any change is required to the list of designated employees excluded from submitting Contributions on Your behalf per Section 4. Such notification should be sent via email to legal@gitlab.com. This text is licensed under the [Creative Commons Attribution 3.0 License](https://creativecommons.org/licenses/by/3.0/) and the original source is the Google Open Source Programs Office. diff --git a/doc/monitoring/health_check.md b/doc/monitoring/health_check.md index 0d17799372f..6cf93c33ec2 100644 --- a/doc/monitoring/health_check.md +++ b/doc/monitoring/health_check.md @@ -1,66 +1 @@ -# Health Check - ->**Note:** This feature was [introduced][ce-3888] in GitLab 8.8. - -GitLab provides a health check endpoint for uptime monitoring on the `health_check` web -endpoint. The health check reports on the overall system status based on the status of -the database connection, the state of the database migrations, and the ability to write -and access the cache. This endpoint can be provided to uptime monitoring services like -[Pingdom][pingdom], [Nagios][nagios-health], and [NewRelic][newrelic-health]. - -## Access Token - -An access token needs to be provided while accessing the health check endpoint. The current -accepted token can be found on the `admin/health_check` page of your GitLab instance. - - - -The access token can be passed as a URL parameter: - -``` -https://gitlab.example.com/health_check.json?token=ACCESS_TOKEN -``` - -or as an HTTP header: - -```bash -curl -H "TOKEN: ACCESS_TOKEN" https://gitlab.example.com/health_check.json -``` - -## Using the Endpoint - -Once you have the access token, health information can be retrieved as plain text, JSON, -or XML using the `health_check` endpoint: - -- `https://gitlab.example.com/health_check?token=ACCESS_TOKEN` -- `https://gitlab.example.com/health_check.json?token=ACCESS_TOKEN` -- `https://gitlab.example.com/health_check.xml?token=ACCESS_TOKEN` - -You can also ask for the status of specific services: - -- `https://gitlab.example.com/health_check/cache.json?token=ACCESS_TOKEN` -- `https://gitlab.example.com/health_check/database.json?token=ACCESS_TOKEN` -- `https://gitlab.example.com/health_check/migrations.json?token=ACCESS_TOKEN` - -For example, the JSON output of the following health check: - -```bash -curl -H "TOKEN: ACCESS_TOKEN" https://gitlab.example.com/health_check.json -``` - -would be like: - -``` -{"healthy":true,"message":"success"} -``` - -## Status - -On failure, the endpoint will return a `500` HTTP status code. On success, the endpoint -will return a valid successful HTTP status code, and a `success` message. Ideally your -uptime monitoring should look for the success message. - -[ce-3888]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3888 -[pingdom]: https://www.pingdom.com -[nagios-health]: https://nagios-plugins.org/doc/man/check_http.html -[newrelic-health]: https://docs.newrelic.com/docs/alerts/alert-policies/downtime-alerts/availability-monitoring +This document was moved to [user/admin_area/monitoring/health_check](../user/admin_area/monitoring/health_check.md). diff --git a/doc/monitoring/performance/gitlab_configuration.md b/doc/monitoring/performance/gitlab_configuration.md index 771584268d9..a669bb28904 100644 --- a/doc/monitoring/performance/gitlab_configuration.md +++ b/doc/monitoring/performance/gitlab_configuration.md @@ -1,40 +1 @@ -# GitLab Configuration - -GitLab Performance Monitoring is disabled by default. To enable it and change any of its -settings, navigate to the Admin area in **Settings > Metrics** -(`/admin/application_settings`). - -The minimum required settings you need to set are the InfluxDB host and port. -Make sure _Enable InfluxDB Metrics_ is checked and hit **Save** to save the -changes. - ---- - - - ---- - -Finally, a restart of all GitLab processes is required for the changes to take -effect: - -```bash -# For Omnibus installations -sudo gitlab-ctl restart - -# For installations from source -sudo service gitlab restart -``` - -## Pending Migrations - -When any migrations are pending, the metrics are disabled until the migrations -have been performed. - ---- - -Read more on: - -- [Introduction to GitLab Performance Monitoring](introduction.md) -- [InfluxDB Configuration](influxdb_configuration.md) -- [InfluxDB Schema](influxdb_schema.md) -- [Grafana Install/Configuration](grafana_configuration.md) +This document was moved to [administration/monitoring/performance/gitlab_configuration](../administration/monitoring/performance/gitlab_configuration.md). diff --git a/doc/monitoring/performance/grafana_configuration.md b/doc/monitoring/performance/grafana_configuration.md index 7947b0fedc4..0d4be02ff5f 100644 --- a/doc/monitoring/performance/grafana_configuration.md +++ b/doc/monitoring/performance/grafana_configuration.md @@ -1,111 +1 @@ -# Grafana Configuration - -[Grafana](http://grafana.org/) is a tool that allows you to visualize time -series metrics through graphs and dashboards. It supports several backend -data stores, including InfluxDB. GitLab writes performance data to InfluxDB -and Grafana will allow you to query InfluxDB to display useful graphs. - -For the easiest installation and configuration, install Grafana on the same -server as InfluxDB. For larger installations, you may want to split out these -services. - -## Installation - -Grafana supplies package repositories (Yum/Apt) for easy installation. -See [Grafana installation documentation](http://docs.grafana.org/installation/) -for detailed steps. - -> **Note**: Before starting Grafana for the first time, set the admin user -and password in `/etc/grafana/grafana.ini`. Otherwise, the default password -will be `admin`. - -## Configuration - -Login as the admin user. Expand the menu by clicking the Grafana logo in the -top left corner. Choose 'Data Sources' from the menu. Then, click 'Add new' -in the top bar. - - - -Fill in the configuration details for the InfluxDB data source. Save and -Test Connection to ensure the configuration is correct. - -- **Name**: InfluxDB -- **Default**: Checked -- **Type**: InfluxDB 0.9.x (Even if you're using InfluxDB 0.10.x) -- **Url**: https://localhost:8086 (Or the remote URL if you've installed InfluxDB -on a separate server) -- **Access**: proxy -- **Database**: gitlab -- **User**: admin (Or the username configured when setting up InfluxDB) -- **Password**: The password configured when you set up InfluxDB - - - -## Apply retention policies and create continuous queries - -If you intend to import the GitLab provided Grafana dashboards, you will need to -set up the right retention policies and continuous queries. The easiest way of -doing this is by using the [influxdb-management](https://gitlab.com/gitlab-org/influxdb-management) -repository. - -To use this repository you must first clone it: - -``` -git clone https://gitlab.com/gitlab-org/influxdb-management.git -cd influxdb-management -``` - -Next you must install the required dependencies: - -``` -gem install bundler -bundle install -``` - -Now you must configure the repository by first copying `.env.example` to `.env` -and then editing the `.env` file to contain the correct InfluxDB settings. Once -configured you can simply run `bundle exec rake` and the InfluxDB database will -be configured for you. - -For more information see the [influxdb-management README](https://gitlab.com/gitlab-org/influxdb-management/blob/master/README.md). - -## Import Dashboards - -You can now import a set of default dashboards that will give you a good -start on displaying useful information. GitLab has published a set of default -[Grafana dashboards][grafana-dashboards] to get you started. Clone the -repository or download a zip/tarball, then follow these steps to import each -JSON file. - -Open the dashboard dropdown menu and click 'Import' - - - -Click 'Choose file' and browse to the location where you downloaded or cloned -the dashboard repository. Pick one of the JSON files to import. - - - -Once the dashboard is imported, be sure to click save icon in the top bar. If -you do not save the dashboard after importing it will be removed when you -navigate away. - - - -Repeat this process for each dashboard you wish to import. - -Alternatively you can automatically import all the dashboards into your Grafana -instance. See the README of the [Grafana dashboards][grafana-dashboards] -repository for more information on this process. - -[grafana-dashboards]: https://gitlab.com/gitlab-org/grafana-dashboards - ---- - -Read more on: - -- [Introduction to GitLab Performance Monitoring](introduction.md) -- [GitLab Configuration](gitlab_configuration.md) -- [InfluxDB Installation/Configuration](influxdb_configuration.md) -- [InfluxDB Schema](influxdb_schema.md) +This document was moved to [administration/monitoring/performance/grafana_configuration](../../administration/monitoring/performance/grafana_configuration.md). diff --git a/doc/monitoring/performance/influxdb_configuration.md b/doc/monitoring/performance/influxdb_configuration.md index c30cd2950d8..02647de1eb0 100644 --- a/doc/monitoring/performance/influxdb_configuration.md +++ b/doc/monitoring/performance/influxdb_configuration.md @@ -1,193 +1 @@ -# InfluxDB Configuration - -The default settings provided by [InfluxDB] are not sufficient for a high traffic -GitLab environment. The settings discussed in this document are based on the -settings GitLab uses for GitLab.com, depending on your own needs you may need to -further adjust them. - -If you are intending to run InfluxDB on the same server as GitLab, make sure -you have plenty of RAM since InfluxDB can use quite a bit depending on traffic. - -Unless you are going with a budget setup, it's advised to run it separately. - -## Requirements - -- InfluxDB 0.9.5 or newer -- A fairly modern version of Linux -- At least 4GB of RAM -- At least 10GB of storage for InfluxDB data - -Note that the RAM and storage requirements can differ greatly depending on the -amount of data received/stored. To limit the amount of stored data users can -look into [InfluxDB Retention Policies][influxdb-retention]. - -## Installation - -Installing InfluxDB is out of the scope of this document. Please refer to the -[InfluxDB documentation]. - -## InfluxDB Server Settings - -Since InfluxDB has many settings that users may wish to customize themselves -(e.g. what port to run InfluxDB on), we'll only cover the essentials. - -The configuration file in question is usually located at -`/etc/influxdb/influxdb.conf`. Whenever you make a change in this file, -InfluxDB needs to be restarted. - -### Storage Engine - -InfluxDB comes with different storage engines and as of InfluxDB 0.9.5 a new -storage engine is available, called [TSM Tree]. All users **must** use the new -`tsm1` storage engine as this [will be the default engine][tsm1-commit] in -upcoming InfluxDB releases. - -Make sure you have the following in your configuration file: - -``` -[data] - dir = "/var/lib/influxdb/data" - engine = "tsm1" -``` - -### Admin Panel - -Production environments should have the InfluxDB admin panel **disabled**. This -feature can be disabled by adding the following to your InfluxDB configuration -file: - -``` -[admin] - enabled = false -``` - -### HTTP - -HTTP is required when using the [InfluxDB CLI] or other tools such as Grafana, -thus it should be enabled. When enabling make sure to _also_ enable -authentication: - -``` -[http] - enabled = true - auth-enabled = true -``` - -_**Note:** Before you enable authentication, you might want to [create an -admin user](#create-a-new-admin-user)._ - -### UDP - -GitLab writes data to InfluxDB via UDP and thus this must be enabled. Enabling -UDP can be done using the following settings: - -``` -[[udp]] - enabled = true - bind-address = ":8089" - database = "gitlab" - batch-size = 1000 - batch-pending = 5 - batch-timeout = "1s" - read-buffer = 209715200 -``` - -This does the following: - -1. Enable UDP and bind it to port 8089 for all addresses. -2. Store any data received in the "gitlab" database. -3. Define a batch of points to be 1000 points in size and allow a maximum of - 5 batches _or_ flush them automatically after 1 second. -4. Define a UDP read buffer size of 200 MB. - -One of the most important settings here is the UDP read buffer size as if this -value is set too low, packets will be dropped. You must also make sure the OS -buffer size is set to the same value, the default value is almost never enough. - -To set the OS buffer size to 200 MB, on Linux you can run the following command: - -```bash -sysctl -w net.core.rmem_max=209715200 -``` - -To make this permanent, add the following to `/etc/sysctl.conf` and restart the -server: - -```bash -net.core.rmem_max=209715200 -``` - -It is **very important** to make sure the buffer sizes are large enough to -handle all data sent to InfluxDB as otherwise you _will_ lose data. The above -buffer sizes are based on the traffic for GitLab.com. Depending on the amount of -traffic, users may be able to use a smaller buffer size, but we highly recommend -using _at least_ 100 MB. - -When enabling UDP, users should take care to not expose the port to the public, -as doing so will allow anybody to write data into your InfluxDB database (as -[InfluxDB's UDP protocol][udp] doesn't support authentication). We recommend either -whitelisting the allowed IP addresses/ranges, or setting up a VLAN and only -allowing traffic from members of said VLAN. - -## Create a new admin user - -If you want to [enable authentication](#http), you might want to [create an -admin user][influx-admin]: - -``` -influx -execute "CREATE USER jeff WITH PASSWORD '1234' WITH ALL PRIVILEGES" -``` - -## Create the `gitlab` database - -Once you get InfluxDB up and running, you need to create a database for GitLab. -Make sure you have changed the [storage engine](#storage-engine) to `tsm1` -before creating a database. - -_**Note:** If you [created an admin user](#create-a-new-admin-user) and enabled -[HTTP authentication](#http), remember to append the username (`-username <username>`) -and password (`-password <password>`) you set earlier to the commands below._ - -Run the following command to create a database named `gitlab`: - -```bash -influx -execute 'CREATE DATABASE gitlab' -``` - -The name **must** be `gitlab`, do not use any other name. - -Next, make sure that the database was successfully created: - -```bash -influx -execute 'SHOW DATABASES' -``` - -The output should be similar to: - -``` -name: databases ---------------- -name -_internal -gitlab -``` - -That's it! Now your GitLab instance should send data to InfluxDB. - ---- - -Read more on: - -- [Introduction to GitLab Performance Monitoring](introduction.md) -- [GitLab Configuration](gitlab_configuration.md) -- [InfluxDB Schema](influxdb_schema.md) -- [Grafana Install/Configuration](grafana_configuration.md) - -[influxdb-retention]: https://docs.influxdata.com/influxdb/v0.9/query_language/database_management/#retention-policy-management -[influxdb documentation]: https://docs.influxdata.com/influxdb/v0.9/ -[influxdb cli]: https://docs.influxdata.com/influxdb/v0.9/tools/shell/ -[udp]: https://docs.influxdata.com/influxdb/v0.9/write_protocols/udp/ -[influxdb]: https://influxdata.com/time-series-platform/influxdb/ -[tsm tree]: https://influxdata.com/blog/new-storage-engine-time-structured-merge-tree/ -[tsm1-commit]: https://github.com/influxdata/influxdb/commit/15d723dc77651bac83e09e2b1c94be480966cb0d -[influx-admin]: https://docs.influxdata.com/influxdb/v0.9/administration/authentication_and_authorization/#create-a-new-admin-user +This document was moved to [administration/monitoring/performance/influxdb_configuration](../administration/monitoring/performance/influxdb_configuration.md). diff --git a/doc/monitoring/performance/influxdb_schema.md b/doc/monitoring/performance/influxdb_schema.md index 41861860b6d..a989e323e04 100644 --- a/doc/monitoring/performance/influxdb_schema.md +++ b/doc/monitoring/performance/influxdb_schema.md @@ -1,88 +1 @@ -# InfluxDB Schema - -The following measurements are currently stored in InfluxDB: - -- `PROCESS_file_descriptors` -- `PROCESS_gc_statistics` -- `PROCESS_memory_usage` -- `PROCESS_method_calls` -- `PROCESS_object_counts` -- `PROCESS_transactions` -- `PROCESS_views` - -Here, `PROCESS` is replaced with either `rails` or `sidekiq` depending on the -process type. In all series, any form of duration is stored in milliseconds. - -## PROCESS_file_descriptors - -This measurement contains the number of open file descriptors over time. The -value field `value` contains the number of descriptors. - -## PROCESS_gc_statistics - -This measurement contains Ruby garbage collection statistics such as the amount -of minor/major GC runs (relative to the last sampling interval), the time spent -in garbage collection cycles, and all fields/values returned by `GC.stat`. - -## PROCESS_memory_usage - -This measurement contains the process' memory usage (in bytes) over time. The -value field `value` contains the number of bytes. - -## PROCESS_method_calls - -This measurement contains the methods called during a transaction along with -their duration, and a name of the transaction action that invoked the method (if -available). The method call duration is stored in the value field `duration`, -while the method name is stored in the tag `method`. The tag `action` contains -the full name of the transaction action. Both the `method` and `action` fields -are in the following format: - -``` -ClassName#method_name -``` - -For example, a method called by the `show` method in the `UsersController` class -would have `action` set to `UsersController#show`. - -## PROCESS_object_counts - -This measurement is used to store retained Ruby objects (per class) and the -amount of retained objects. The number of objects is stored in the `count` value -field while the class name is stored in the `type` tag. - -## PROCESS_transactions - -This measurement is used to store basic transaction details such as the time it -took to complete a transaction, how much time was spent in SQL queries, etc. The -following value fields are available: - -| Value | Description | -| ----- | ----------- | -| `duration` | The total duration of the transaction | -| `allocated_memory` | The amount of bytes allocated while the transaction was running. This value is only reliable when using single-threaded application servers | -| `method_duration` | The total time spent in method calls | -| `sql_duration` | The total time spent in SQL queries | -| `view_duration` | The total time spent in views | - -## PROCESS_views - -This measurement is used to store view rendering timings for a transaction. The -following value fields are available: - -| Value | Description | -| ----- | ----------- | -| `duration` | The rendering time of the view | -| `view` | The path of the view, relative to the application's root directory | - -The `action` tag contains the action name of the transaction that rendered the -view. - ---- - -Read more on: - -- [Introduction to GitLab Performance Monitoring](introduction.md) -- [GitLab Configuration](gitlab_configuration.md) -- [InfluxDB Configuration](influxdb_configuration.md) -- [Grafana Install/Configuration](grafana_configuration.md) +This document was moved to [administration/monitoring/performance/influxdb_schema](../administration/monitoring/performance/influxdb_schema.md). diff --git a/doc/monitoring/performance/introduction.md b/doc/monitoring/performance/introduction.md index 79904916b7e..ab3f3ac1664 100644 --- a/doc/monitoring/performance/introduction.md +++ b/doc/monitoring/performance/introduction.md @@ -1,65 +1 @@ -# GitLab Performance Monitoring - -GitLab comes with its own application performance measuring system as of GitLab -8.4, simply called "GitLab Performance Monitoring". GitLab Performance Monitoring is available in both the -Community and Enterprise editions. - -Apart from this introduction, you are advised to read through the following -documents in order to understand and properly configure GitLab Performance Monitoring: - -- [GitLab Configuration](gitlab_configuration.md) -- [InfluxDB Install/Configuration](influxdb_configuration.md) -- [InfluxDB Schema](influxdb_schema.md) -- [Grafana Install/Configuration](grafana_configuration.md) - -## Introduction to GitLab Performance Monitoring - -GitLab Performance Monitoring makes it possible to measure a wide variety of statistics -including (but not limited to): - -- The time it took to complete a transaction (a web request or Sidekiq job). -- The time spent in running SQL queries and rendering HAML views. -- The time spent executing (instrumented) Ruby methods. -- Ruby object allocations, and retained objects in particular. -- System statistics such as the process' memory usage and open file descriptors. -- Ruby garbage collection statistics. - -Metrics data is written to [InfluxDB][influxdb] over [UDP][influxdb-udp]. Stored -data can be visualized using [Grafana][grafana] or any other application that -supports reading data from InfluxDB. Alternatively data can be queried using the -InfluxDB CLI. - -## Metric Types - -Two types of metrics are collected: - -1. Transaction specific metrics. -1. Sampled metrics, collected at a certain interval in a separate thread. - -### Transaction Metrics - -Transaction metrics are metrics that can be associated with a single -transaction. This includes statistics such as the transaction duration, timings -of any executed SQL queries, time spent rendering HAML views, etc. These metrics -are collected for every Rack request and Sidekiq job processed. - -### Sampled Metrics - -Sampled metrics are metrics that can't be associated with a single transaction. -Examples include garbage collection statistics and retained Ruby objects. These -metrics are collected at a regular interval. This interval is made up out of two -parts: - -1. A user defined interval. -1. A randomly generated offset added on top of the interval, the same offset - can't be used twice in a row. - -The actual interval can be anywhere between a half of the defined interval and a -half above the interval. For example, for a user defined interval of 15 seconds -the actual interval can be anywhere between 7.5 and 22.5. The interval is -re-generated for every sampling run instead of being generated once and re-used -for the duration of the process' lifetime. - -[influxdb]: https://influxdata.com/time-series-platform/influxdb/ -[influxdb-udp]: https://docs.influxdata.com/influxdb/v0.9/write_protocols/udp/ -[grafana]: http://grafana.org/ +This document was moved to [administration/monitoring/performance/introduction](../administration/monitoring/performance/introduction.md). diff --git a/doc/operations/README.md b/doc/operations/README.md index 6a35dab7b6c..58f16aff7bd 100644 --- a/doc/operations/README.md +++ b/doc/operations/README.md @@ -1,5 +1 @@ -# GitLab operations - -- [Sidekiq MemoryKiller](sidekiq_memory_killer.md) -- [Cleaning up Redis sessions](cleaning_up_redis_sessions.md) -- [Understanding Unicorn and unicorn-worker-killer](unicorn.md) +This document was moved to [administration/operations](../administration/operations.md). diff --git a/doc/operations/cleaning_up_redis_sessions.md b/doc/operations/cleaning_up_redis_sessions.md index 93521e976d5..2a1d0a8c8eb 100644 --- a/doc/operations/cleaning_up_redis_sessions.md +++ b/doc/operations/cleaning_up_redis_sessions.md @@ -1,52 +1 @@ -# Cleaning up stale Redis sessions - -Since version 6.2, GitLab stores web user sessions as key-value pairs in Redis. -Prior to GitLab 7.3, user sessions did not automatically expire from Redis. If -you have been running a large GitLab server (thousands of users) since before -GitLab 7.3 we recommend cleaning up stale sessions to compact the Redis -database after you upgrade to GitLab 7.3. You can also perform a cleanup while -still running GitLab 7.2 or older, but in that case new stale sessions will -start building up again after you clean up. - -In GitLab versions prior to 7.3.0, the session keys in Redis are 16-byte -hexadecimal values such as '976aa289e2189b17d7ef525a6702ace9'. Starting with -GitLab 7.3.0, the keys are -prefixed with 'session:gitlab:', so they would look like -'session:gitlab:976aa289e2189b17d7ef525a6702ace9'. Below we describe how to -remove the keys in the old format. - -First we define a shell function with the proper Redis connection details. - -``` -rcli() { - # This example works for Omnibus installations of GitLab 7.3 or newer. For an - # installation from source you will have to change the socket path and the - # path to redis-cli. - sudo /opt/gitlab/embedded/bin/redis-cli -s /var/opt/gitlab/redis/redis.socket "$@" -} - -# test the new shell function; the response should be PONG -rcli ping -``` - -Now we do a search to see if there are any session keys in the old format for -us to clean up. - -``` -# returns the number of old-format session keys in Redis -rcli keys '*' | grep '^[a-f0-9]\{32\}$' | wc -l -``` - -If the number is larger than zero, you can proceed to expire the keys from -Redis. If the number is zero there is nothing to clean up. - -``` -# Tell Redis to expire each matched key after 600 seconds. -rcli keys '*' | grep '^[a-f0-9]\{32\}$' | awk '{ print "expire", $0, 600 }' | rcli -# This will print '(integer) 1' for each key that gets expired. -``` - -Over the next 15 minutes (10 minutes expiry time plus 5 minutes Redis -background save interval) your Redis database will be compacted. If you are -still using GitLab 7.2, users who are not clicking around in GitLab during the -10 minute expiry window will be signed out of GitLab. +This document was moved to [administration/operations/cleaning_up_redis_sessions](../administration/operations/cleaning_up_redis_sessions.md). diff --git a/doc/operations/moving_repositories.md b/doc/operations/moving_repositories.md index 54adb99386a..c54bca324a5 100644 --- a/doc/operations/moving_repositories.md +++ b/doc/operations/moving_repositories.md @@ -1,180 +1 @@ -# Moving repositories managed by GitLab - -Sometimes you need to move all repositories managed by GitLab to -another filesystem or another server. In this document we will look -at some of the ways you can copy all your repositories from -`/var/opt/gitlab/git-data/repositories` to `/mnt/gitlab/repositories`. - -We will look at three scenarios: the target directory is empty, the -target directory contains an outdated copy of the repositories, and -how to deal with thousands of repositories. - -**Each of the approaches we list can/will overwrite data in the -target directory `/mnt/gitlab/repositories`. Do not mix up the -source and the target.** - -## Target directory is empty: use a tar pipe - -If the target directory `/mnt/gitlab/repositories` is empty the -simplest thing to do is to use a tar pipe. This method has low -overhead and tar is almost always already installed on your system. -However, it is not possible to resume an interrupted tar pipe: if -that happens then all data must be copied again. - -``` -# As the git user -tar -C /var/opt/gitlab/git-data/repositories -cf - -- . |\ - tar -C /mnt/gitlab/repositories -xf - -``` - -If you want to see progress, replace `-xf` with `-xvf`. - -### Tar pipe to another server - -You can also use a tar pipe to copy data to another server. If your -'git' user has SSH access to the newserver as 'git@newserver', you -can pipe the data through SSH. - -``` -# As the git user -tar -C /var/opt/gitlab/git-data/repositories -cf - -- . |\ - ssh git@newserver tar -C /mnt/gitlab/repositories -xf - -``` - -If you want to compress the data before it goes over the network -(which will cost you CPU cycles) you can replace `ssh` with `ssh -C`. - -## The target directory contains an outdated copy of the repositories: use rsync - -If the target directory already contains a partial / outdated copy -of the repositories it may be wasteful to copy all the data again -with tar. In this scenario it is better to use rsync. This utility -is either already installed on your system or easily installable -via apt, yum etc. - -``` -# As the 'git' user -rsync -a --delete /var/opt/gitlab/git-data/repositories/. \ - /mnt/gitlab/repositories -``` - -The `/.` in the command above is very important, without it you can -easily get the wrong directory structure in the target directory. -If you want to see progress, replace `-a` with `-av`. - -### Single rsync to another server - -If the 'git' user on your source system has SSH access to the target -server you can send the repositories over the network with rsync. - -``` -# As the 'git' user -rsync -a --delete /var/opt/gitlab/git-data/repositories/. \ - git@newserver:/mnt/gitlab/repositories -``` - -## Thousands of Git repositories: use one rsync per repository - -Every time you start an rsync job it has to inspect all files in -the source directory, all files in the target directory, and then -decide what files to copy or not. If the source or target directory -has many contents this startup phase of rsync can become a burden -for your GitLab server. In cases like this you can make rsync's -life easier by dividing its work in smaller pieces, and sync one -repository at a time. - -In addition to rsync we will use [GNU -Parallel](http://www.gnu.org/software/parallel/). This utility is -not included in GitLab so you need to install it yourself with apt -or yum. Also note that the GitLab scripts we used below were added -in GitLab 8.1. - -** This process does not clean up repositories at the target location that no -longer exist at the source. ** If you start using your GitLab instance with -`/mnt/gitlab/repositories`, you need to run `gitlab-rake gitlab:cleanup:repos` -after switching to the new repository storage directory. - -### Parallel rsync for all repositories known to GitLab - -This will sync repositories with 10 rsync processes at a time. We keep -track of progress so that the transfer can be restarted if necessary. - -First we create a new directory, owned by 'git', to hold transfer -logs. We assume the directory is empty before we start the transfer -procedure, and that we are the only ones writing files in it. - -``` -# Omnibus -sudo mkdir /var/opt/gitlab/transfer-logs -sudo chown git:git /var/opt/gitlab/transfer-logs - -# Source -sudo -u git -H mkdir /home/git/transfer-logs -``` - -We seed the process with a list of the directories we want to copy. - -``` -# Omnibus -sudo -u git sh -c 'gitlab-rake gitlab:list_repos > /var/opt/gitlab/transfer-logs/all-repos-$(date +%s).txt' - -# Source -cd /home/git/gitlab -sudo -u git -H sh -c 'bundle exec rake gitlab:list_repos > /home/git/transfer-logs/all-repos-$(date +%s).txt' -``` - -Now we can start the transfer. The command below is idempotent, and -the number of jobs done by GNU Parallel should converge to zero. If it -does not some repositories listed in all-repos-1234.txt may have been -deleted/renamed before they could be copied. - -``` -# Omnibus -sudo -u git sh -c ' -cat /var/opt/gitlab/transfer-logs/* | sort | uniq -u |\ - /usr/bin/env JOBS=10 \ - /opt/gitlab/embedded/service/gitlab-rails/bin/parallel-rsync-repos \ - /var/opt/gitlab/transfer-logs/success-$(date +%s).log \ - /var/opt/gitlab/git-data/repositories \ - /mnt/gitlab/repositories -' - -# Source -cd /home/git/gitlab -sudo -u git -H sh -c ' -cat /home/git/transfer-logs/* | sort | uniq -u |\ - /usr/bin/env JOBS=10 \ - bin/parallel-rsync-repos \ - /home/git/transfer-logs/success-$(date +%s).log \ - /home/git/repositories \ - /mnt/gitlab/repositories -` -``` - -### Parallel rsync only for repositories with recent activity - -Suppose you have already done one sync that started after 2015-10-1 12:00 UTC. -Then you might only want to sync repositories that were changed via GitLab -_after_ that time. You can use the 'SINCE' variable to tell 'rake -gitlab:list_repos' to only print repositories with recent activity. - -``` -# Omnibus -sudo gitlab-rake gitlab:list_repos SINCE='2015-10-1 12:00 UTC' |\ - sudo -u git \ - /usr/bin/env JOBS=10 \ - /opt/gitlab/embedded/service/gitlab-rails/bin/parallel-rsync-repos \ - success-$(date +%s).log \ - /var/opt/gitlab/git-data/repositories \ - /mnt/gitlab/repositories - -# Source -cd /home/git/gitlab -sudo -u git -H bundle exec rake gitlab:list_repos SINCE='2015-10-1 12:00 UTC' |\ - sudo -u git -H \ - /usr/bin/env JOBS=10 \ - bin/parallel-rsync-repos \ - success-$(date +%s).log \ - /home/git/repositories \ - /mnt/gitlab/repositories -``` +This document was moved to [administration/operations/moving_repositories](../administration/operations/moving_repositories.md). diff --git a/doc/operations/sidekiq_memory_killer.md b/doc/operations/sidekiq_memory_killer.md index b5e78348989..cf7c3b2e2ed 100644 --- a/doc/operations/sidekiq_memory_killer.md +++ b/doc/operations/sidekiq_memory_killer.md @@ -1,40 +1 @@ -# Sidekiq MemoryKiller - -The GitLab Rails application code suffers from memory leaks. For web requests -this problem is made manageable using -[unicorn-worker-killer](https://github.com/kzk/unicorn-worker-killer) which -restarts Unicorn worker processes in between requests when needed. The Sidekiq -MemoryKiller applies the same approach to the Sidekiq processes used by GitLab -to process background jobs. - -Unlike unicorn-worker-killer, which is enabled by default for all GitLab -installations since GitLab 6.4, the Sidekiq MemoryKiller is enabled by default -_only_ for Omnibus packages. The reason for this is that the MemoryKiller -relies on Runit to restart Sidekiq after a memory-induced shutdown and GitLab -installations from source do not all use Runit or an equivalent. - -With the default settings, the MemoryKiller will cause a Sidekiq restart no -more often than once every 15 minutes, with the restart causing about one -minute of delay for incoming background jobs. - -## Configuring the MemoryKiller - -The MemoryKiller is controlled using environment variables. - -- `SIDEKIQ_MEMORY_KILLER_MAX_RSS`: if this variable is set, and its value is - greater than 0, then after each Sidekiq job, the MemoryKiller will check the - RSS of the Sidekiq process that executed the job. If the RSS of the Sidekiq - process (expressed in kilobytes) exceeds SIDEKIQ_MEMORY_KILLER_MAX_RSS, a - delayed shutdown is triggered. The default value for Omnibus packages is set - [in the omnibus-gitlab - repository](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-cookbooks/gitlab/attributes/default.rb). -- `SIDEKIQ_MEMORY_KILLER_GRACE_TIME`: defaults 900 seconds (15 minutes). When - a shutdown is triggered, the Sidekiq process will keep working normally for - another 15 minutes. -- `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_WAIT`: defaults to 30 seconds. When the grace - time has expired, the MemoryKiller tells Sidekiq to stop accepting new jobs. - Existing jobs get 30 seconds to finish. After that, the MemoryKiller tells - Sidekiq to shut down, and an external supervision mechanism (e.g. Runit) must - restart Sidekiq. -- `SIDEKIQ_MEMORY_KILLER_SHUTDOWN_SIGNAL`: defaults to `SIGKILL`. The name of - the final signal sent to the Sidekiq process when we want it to shut down. +This document was moved to [administration/operations/sidekiq_memory_killer](../administration/operations/sidekiq_memory_killer.md). diff --git a/doc/operations/unicorn.md b/doc/operations/unicorn.md index bad61151bda..fbc9697b755 100644 --- a/doc/operations/unicorn.md +++ b/doc/operations/unicorn.md @@ -1,86 +1 @@ -# Understanding Unicorn and unicorn-worker-killer - -## Unicorn - -GitLab uses [Unicorn](http://unicorn.bogomips.org/), a pre-forking Ruby web -server, to handle web requests (web browsers and Git HTTP clients). Unicorn is -a daemon written in Ruby and C that can load and run a Ruby on Rails -application; in our case the Rails application is GitLab Community Edition or -GitLab Enterprise Edition. - -Unicorn has a multi-process architecture to make better use of available CPU -cores (processes can run on different cores) and to have stronger fault -tolerance (most failures stay isolated in only one process and cannot take down -GitLab entirely). On startup, the Unicorn 'master' process loads a clean Ruby -environment with the GitLab application code, and then spawns 'workers' which -inherit this clean initial environment. The 'master' never handles any -requests, that is left to the workers. The operating system network stack -queues incoming requests and distributes them among the workers. - -In a perfect world, the master would spawn its pool of workers once, and then -the workers handle incoming web requests one after another until the end of -time. In reality, worker processes can crash or time out: if the master notices -that a worker takes too long to handle a request it will terminate the worker -process with SIGKILL ('kill -9'). No matter how the worker process ended, the -master process will replace it with a new 'clean' process again. Unicorn is -designed to be able to replace 'crashed' workers without dropping user -requests. - -This is what a Unicorn worker timeout looks like in `unicorn_stderr.log`. The -master process has PID 56227 below. - -``` -[2015-06-05T10:58:08.660325 #56227] ERROR -- : worker=10 PID:53009 timeout (61s > 60s), killing -[2015-06-05T10:58:08.699360 #56227] ERROR -- : reaped #<Process::Status: pid 53009 SIGKILL (signal 9)> worker=10 -[2015-06-05T10:58:08.708141 #62538] INFO -- : worker=10 spawned pid=62538 -[2015-06-05T10:58:08.708824 #62538] INFO -- : worker=10 ready -``` - -### Tunables - -The main tunables for Unicorn are the number of worker processes and the -request timeout after which the Unicorn master terminates a worker process. -See the [omnibus-gitlab Unicorn settings -documentation](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/settings/unicorn.md) -if you want to adjust these settings. - -## unicorn-worker-killer - -GitLab has memory leaks. These memory leaks manifest themselves in long-running -processes, such as Unicorn workers. (The Unicorn master process is not known to -leak memory, probably because it does not handle user requests.) - -To make these memory leaks manageable, GitLab comes with the -[unicorn-worker-killer gem](https://github.com/kzk/unicorn-worker-killer). This -gem [monkey-patches](https://en.wikipedia.org/wiki/Monkey_patch) the Unicorn -workers to do a memory self-check after every 16 requests. If the memory of the -Unicorn worker exceeds a pre-set limit then the worker process exits. The -Unicorn master then automatically replaces the worker process. - -This is a robust way to handle memory leaks: Unicorn is designed to handle -workers that 'crash' so no user requests will be dropped. The -unicorn-worker-killer gem is designed to only terminate a worker process _in -between requests_, so no user requests are affected. - -This is what a Unicorn worker memory restart looks like in unicorn_stderr.log. -You see that worker 4 (PID 125918) is inspecting itself and decides to exit. -The threshold memory value was 254802235 bytes, about 250MB. With GitLab this -threshold is a random value between 200 and 250 MB. The master process (PID -117565) then reaps the worker process and spawns a new 'worker 4' with PID -127549. - -``` -[2015-06-05T12:07:41.828374 #125918] WARN -- : #<Unicorn::HttpServer:0x00000002734770>: worker (pid: 125918) exceeds memory limit (256413696 bytes > 254802235 bytes) -[2015-06-05T12:07:41.828472 #125918] WARN -- : Unicorn::WorkerKiller send SIGQUIT (pid: 125918) alive: 23 sec (trial 1) -[2015-06-05T12:07:42.025916 #117565] INFO -- : reaped #<Process::Status: pid 125918 exit 0> worker=4 -[2015-06-05T12:07:42.034527 #127549] INFO -- : worker=4 spawned pid=127549 -[2015-06-05T12:07:42.035217 #127549] INFO -- : worker=4 ready -``` - -One other thing that stands out in the log snippet above, taken from -GitLab.com, is that 'worker 4' was serving requests for only 23 seconds. This -is a normal value for our current GitLab.com setup and traffic. - -The high frequency of Unicorn memory restarts on some GitLab sites can be a -source of confusion for administrators. Usually they are a [red -herring](https://en.wikipedia.org/wiki/Red_herring). +This document was moved to [administration/operations/unicorn](../administration/operations/unicorn.md). diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 5fa96736d59..26baffdf792 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -2,33 +2,47 @@  -## Create a backup of the GitLab system - -A backup creates an archive file that contains the database, all repositories and all attachments. -This archive will be saved in backup_path (see `config/gitlab.yml`). -The filename will be `[TIMESTAMP]_gitlab_backup.tar`. This timestamp can be used to restore an specific backup. -You can only restore a backup to exactly the same version of GitLab that you created it -on, for example 7.2.1. The best way to migrate your repositories from one server to +An application data backup creates an archive file that contains the database, +all repositories and all attachments. +This archive will be saved in `backup_path`, which is specified in the +`config/gitlab.yml` file. +The filename will be `[TIMESTAMP]_gitlab_backup.tar`, where `TIMESTAMP` +identifies the time at which each backup was created. + +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. -You need to keep a separate copy of `/etc/gitlab/gitlab-secrets.json` +To restore a backup, you will also need to restore `/etc/gitlab/gitlab-secrets.json` (for omnibus packages) or `/home/git/gitlab/.secret` (for installations -from source). This file contains the database encryption key used -for two-factor authentication. If you restore a GitLab backup without -restoring the database encryption key, users who have two-factor +from source). This file contains the database encryption key and CI secret +variables used for two-factor authentication. If you fail to restore this +encryption key file along with the application data backup, users with two-factor authentication enabled will lose access to your GitLab server. +## Create a backup of the GitLab system + +Use this command if you've installed GitLab with the Omnibus package: ``` -# use this command if you've installed GitLab with the Omnibus package sudo gitlab-rake gitlab:backup:create - -# if you've installed GitLab from source +``` +Use this if you've installed GitLab from source: +``` sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production ``` -Also you can choose what should be backed up by adding environment variable SKIP. Available options: db, -uploads (attachments), repositories, builds(CI build output logs), artifacts (CI build artifacts), lfs (LFS objects). -Use a comma to specify several options at the same time. +You can specify that portions of the application data be skipped using the +environment variable `SKIP`. You can skip: + +- `db` (database) +- `uploads` (attachments) +- `repositories` (Git repositories data) +- `builds` (CI build output logs) +- `artifacts` (CI build artifacts) +- `lfs` (LFS objects) +- `registry` (Container Registry images) + +Separate multiple data types to skip using a comma. For example: ``` sudo gitlab-rake gitlab:backup:create SKIP=db,uploads @@ -68,7 +82,7 @@ Deleting old backups... [SKIPPING] Starting with GitLab 7.4 you can let the backup script upload the '.tar' file it creates. It uses the [Fog library](http://fog.io/) to perform the upload. In the example below we use Amazon S3 for storage. -But Fog also lets you use [other storage providers](http://fog.io/storage/). +Fog also supports [other storage providers](http://fog.io/storage/). For omnibus packages: @@ -78,6 +92,9 @@ gitlab_rails['backup_upload_connection'] = { 'region' => 'eu-west-1', 'aws_access_key_id' => 'AKIAKIAKI', 'aws_secret_access_key' => 'secret123' + # If using an IAM Profile, leave aws_access_key_id & aws_secret_access_key empty + # ie. 'aws_access_key_id' => '', + # 'use_iam_profile' => 'true' } gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket' ``` @@ -94,6 +111,9 @@ For installations from source: region: eu-west-1 aws_access_key_id: AKIAKIAKI aws_secret_access_key: 'secret123' + # If using an IAM Profile, leave aws_access_key_id & aws_secret_access_key empty + # ie. aws_access_key_id: '' + # use_iam_profile: 'true' # The remote 'directory' to store your backups. For S3, this would be the bucket name. remote_directory: 'my.s3.bucket' # Turns on AWS Server-Side Encryption with Amazon S3-Managed Keys for backups, this is optional @@ -154,7 +174,7 @@ with the name of your bucket: ### Uploading to locally mounted shares You may also send backups to a mounted share (`NFS` / `CIFS` / `SMB` / etc.) by -using the [`Local`](https://github.com/fog/fog-local#usage) storage provider. +using the Fog [`Local`](https://github.com/fog/fog-local#usage) storage provider. The directory pointed to by the `local_root` key **must** be owned by the `git` user **when mounted** (mounting with the `uid=` of the `git` user for `CIFS` and `SMB`) or the user that you are executing the backup tasks under (for omnibus @@ -221,11 +241,12 @@ of using encryption in the first place! If you use an Omnibus package please see the [instructions in the readme to backup your configuration](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/README.md#backup-and-restore-omnibus-gitlab-configuration). If you have a cookbook installation there should be a copy of your configuration in Chef. -If you have an installation from source, please consider backing up your `.secret` file, `gitlab.yml` file, any SSL keys and certificates, and your [SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079). +If you installed from source, please consider backing up your `config/secrets.yml` file, `gitlab.yml` file, any SSL keys and certificates, and your [SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079). -At the very **minimum** you should backup `/etc/gitlab/gitlab-secrets.json` -(Omnibus) or `/home/git/gitlab/.secret` (source) to preserve your -database encryption key. +At the very **minimum** you should backup `/etc/gitlab/gitlab.rb` and +`/etc/gitlab/gitlab-secrets.json` (Omnibus), or +`/home/git/gitlab/config/secrets.yml` (source) to preserve your database +encryption key. ## Restore a previously created backup @@ -240,11 +261,11 @@ the SQL database it needs to import data into ('gitlabhq_production'). All existing data will be either erased (SQL) or moved to a separate directory (repositories, uploads). -If some or all of your GitLab users are using two-factor authentication -(2FA) then you must also make sure to restore -`/etc/gitlab/gitlab-secrets.json` (Omnibus) or `/home/git/gitlab/.secret` -(installations from source). Note that you need to run `gitlab-ctl -reconfigure` after changing `gitlab-secrets.json`. +If some or all of your GitLab users are using two-factor authentication (2FA) +then you must also make sure to restore `/etc/gitlab/gitlab.rb` and +`/etc/gitlab/gitlab-secrets.json` (Omnibus), or +`/home/git/gitlab/config/secrets.yml` (installations from source). Note that you +need to run `gitlab-ctl reconfigure` after changing `gitlab-secrets.json`. ### Installation from source diff --git a/doc/raketasks/user_management.md b/doc/raketasks/user_management.md index 629d38efc53..044b104f5c2 100644 --- a/doc/raketasks/user_management.md +++ b/doc/raketasks/user_management.md @@ -60,8 +60,8 @@ block_auto_created_users: false ## Disable Two-factor Authentication (2FA) for all users This task will disable 2FA for all users that have it enabled. This can be -useful if GitLab's `.secret` file has been lost and users are unable to login, -for example. +useful if GitLab's `config/secrets.yml` file has been lost and users are unable +to login, for example. ```bash # omnibus-gitlab @@ -70,3 +70,18 @@ sudo gitlab-rake gitlab:two_factor:disable_for_all_users # installation from source bundle exec rake gitlab:two_factor:disable_for_all_users RAILS_ENV=production ``` + +## Clear authentication tokens for all users. Important! Data loss! + +Clear authentication tokens for all users in the GitLab database. This +task is useful if your users' authentication tokens might have been exposed in +any way. All the existing tokens will become invalid, and new tokens are +automatically generated upon sign-in or user modification. + +``` +# omnibus-gitlab +sudo gitlab-rake gitlab:users:clear_all_authentication_tokens + +# installation from source +bundle exec rake gitlab:users:clear_all_authentication_tokens RAILS_ENV=production +``` diff --git a/doc/university/README.md b/doc/university/README.md new file mode 100644 index 00000000000..e71e49c33c8 --- /dev/null +++ b/doc/university/README.md @@ -0,0 +1,215 @@ +# GitLab University + +GitLab University is the best place to learn about **Version Control with Git and GitLab**. + +It doesn't replace, but accompanies our great [Documentation](http://docs.gitlab.com) +and [Blog Articles](https://about.gitlab.com/blog/). + +Would you like to contribute to GitLab University? Then please take a look at our contribution [process](/process) for more information. + +## Gitlab University Curriculum + +The curriculum is composed of GitLab videos, screencasts, presentations, projects and external GitLab content hosted on other services and has been organized into the following sections. + +1. [GitLab Beginner](#beginner) +1. [GitLab Intermediate](#intermediate) +1. [GitLab Advanced](#advanced) +1. [External Articles](#external) +1. [Resources for GitLab Team Members](#team) + +--- + +### 1. <a name="beginner"></a> GitLab Beginner + +#### 1.1. Version Control and Git + +1. [Version Control Systems](https://docs.google.com/presentation/d/16sX7hUrCZyOFbpvnrAFrg6tVO5_yT98IgdAqOmXwBho/edit#slide=id.g72f2e4906_2_29) +1. [Operating Systems and How Git Works](https://drive.google.com/a/gitlab.com/file/d/0B41DBToSSIG_OVYxVFJDOGI3Vzg/view?usp=sharing) +1. [Code School: An Introduction to Git](https://www.codeschool.com/account/courses/try-git) + +#### 1.2. GitLab Basics + +1. [An Overview of GitLab.com - Video](https://www.youtube.com/watch?v=WaiL5DGEMR4) +1. [Why Use Git and GitLab - Slides](https://docs.google.com/a/gitlab.com/presentation/d/1RcZhFmn5VPvoFu6UMxhMOy7lAsToeBZRjLRn0LIdaNc/edit?usp=drive_web) +1. [GitLab Basics - Article](http://doc.gitlab.com/ce/gitlab-basics/README.html) +1. [Git and GitLab Basics - Video](https://www.youtube.com/watch?v=03wb9FvO4Ak&index=5&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) +1. [Git and GitLab Basics - Online Course](https://courses.platzi.com/classes/git-gitlab/concepto/part-1/part-23370/material/) +1. [Comparison of GitLab Versions](https://about.gitlab.com/features/#compare) + +#### 1.3. Your GitLab Account + +1. [Create a GitLab Account - Online Course](https://courses.platzi.com/classes/git-gitlab/concepto/first-steps/create-an-account-on-gitlab/material/) +1. [Create and Add your SSH key to GitLab - Video](https://www.youtube.com/watch?v=54mxyLo3Mqk) + +#### 1.4. GitLab Projects + +1. [Repositories, Projects and Groups - Video](https://www.youtube.com/watch?v=4TWfh1aKHHw&index=1&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) +1. [Creating a Project in GitLab - Video](https://www.youtube.com/watch?v=7p0hrpNaJ14) +1. [How to Create Files and Directories](https://about.gitlab.com/2016/02/10/feature-highlight-create-files-and-directories-from-files-page/) +1. [GitLab Todos](https://about.gitlab.com/2016/03/02/gitlab-todos-feature-highlight/) +1. [GitLab's Work in Progress (WIP) Flag](https://about.gitlab.com/2016/01/08/feature-highlight-wip/) + +#### 1.5. Migrating from other Source Control + +1. [Migrating from BitBucket/Stash](http://doc.gitlab.com/ee/workflow/importing/import_projects_from_bitbucket.html) +1. [Migrating from GitHub](http://doc.gitlab.com/ee/workflow/importing/import_projects_from_github.html) +1. [Migrating from SVN](http://doc.gitlab.com/ee/workflow/importing/migrating_from_svn.html) +1. [Migrating from Fogbugz](http://doc.gitlab.com/ee/workflow/importing/import_projects_from_fogbugz.html) + +#### 1.6. GitLab Inc. + +1. [About GitLab](https://about.gitlab.com/about/) +1. [GitLab Direction](https://about.gitlab.com/direction/) +1. [GitLab Master Plan](https://about.gitlab.com/2016/09/13/gitlab-master-plan/) +1. [Making GitLab Great for Everyone - Video](https://www.youtube.com/watch?v=GGC40y4vMx0) - Response to "Dear GitHub" letter +1. [Using Innersourcing to Improve Collaboration](https://about.gitlab.com/2014/09/05/innersourcing-using-the-open-source-workflow-to-improve-collaboration-within-an-organization/) +1. [The Software Development Market and GitLab - Video](https://www.youtube.com/watch?v=sXlhgPK1NTY&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=6) - [Slides](https://docs.google.com/presentation/d/1vCU-NbZWz8NTNK8Vu3y4zGMAHb5DpC8PE5mHtw1PWfI/edit) + +#### 1.7 Community and Support + +1. [Getting Help](https://about.gitlab.com/getting-help/) + - Proposing Features and Reporting and Tracking bugs for GitLab + - The GitLab IRC channel, Gitter Chat Room, Community Forum and Mailing List + - Getting Technical Support + - Being part of our Great Community and Contributing to GitLab +1. [Getting Started with the GitLab Development Kit (GDK)](https://about.gitlab.com/2016/06/08/getting-started-with-gitlab-development-kit/) +1. [Contributing Technical Articles to the GitLab Blog](https://about.gitlab.com/2016/01/26/call-for-writers/) +1. [GitLab Training Workshops](https://about.gitlab.com/training) + +#### 1.8 GitLab Training Material + +1. [Git and GitLab Terminology](glossary/README.md) +1. [Git and GitLab Workshop - Slides](https://docs.google.com/presentation/d/1JzTYD8ij9slejV2-TO-NzjCvlvj6mVn9BORePXNJoMI/edit?usp=drive_web) +1. [Git and GitLab Revision](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/university/training/end-user) + +--- + +### 2. <a name="intermediate"></a> GitLab Intermediate + +#### 2.1 GitLab Pages + +1. [Using any Static Site Generator with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/) +1. [Securing GitLab Pages with SSL](https://about.gitlab.com/2016/06/24/secure-gitlab-pages-with-startssl/) +1. [GitLab Pages Documentation](http://doc.gitlab.com/ee/pages/README.html) + +#### 2.2. GitLab Issues + +1. [Markdown in GitLab](http://doc.gitlab.com/ce/markdown/markdown.html) +1. [Issues and Merge Requests - Video](https://www.youtube.com/watch?v=raXvuwet78M) +1. [Due Dates and Milestones fro GitLab Issues](https://about.gitlab.com/2016/08/05/feature-highlight-set-dates-for-issues/) +1. [How to Use GitLab Labels](https://about.gitlab.com/2016/08/17/using-gitlab-labels/) +1. [Applying GitLab Labels Automatically](https://about.gitlab.com/2016/08/19/applying-gitlab-labels-automatically/) +1. [GitLab Issue Board - Product Page](https://about.gitlab.com/solutions/issueboard/) +1. [An Overview of GitLab Issue Board](https://about.gitlab.com/2016/08/22/announcing-the-gitlab-issue-board/) +1. [Designing GitLab Issue Board](https://about.gitlab.com/2016/08/31/designing-issue-boards/) +1. [From Idea to Production with GitLab - Video](https://www.youtube.com/watch?v=25pHyknRgEo&index=14&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) + +#### 2.3. Continuous Integration + +1. [Operating Systems, Servers, VMs, Containers and Unix - Video](https://www.youtube.com/watch?v=V61kL6IC-zY&index=8&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) +1. [GitLab CI - Product Page](https://about.gitlab.com/gitlab-ci/) +1. [Getting started with GitLab and GitLab CI](https://about.gitlab.com/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/) +1. [GitLab Container Registry](https://about.gitlab.com/2016/05/23/gitlab-container-registry/) +1. [GitLab and Docker - Video](https://www.youtube.com/watch?v=ugOrCcbdHko&index=12&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) +1. [How we scale GitLab with built in Docker](https://about.gitlab.com/2016/06/21/how-we-scale-gitlab-by-having-docker-built-in/) +1. [Continuous Integration, Delivery, and Deployment with GitLab](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/) +1. [Deployments and Environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) +1. [Sequential, Parallel or Custom Pipelines](https://about.gitlab.com/2016/07/29/the-basics-of-gitlab-ci/) +1. [Setting up GitLab Runner For Continuous Integration](https://about.gitlab.com/2016/03/01/gitlab-runner-with-docker/) +1. [Setting up GitLab Runner on DigitalOcean](https://about.gitlab.com/2016/04/19/how-to-set-up-gitlab-runner-on-digitalocean/) +1. [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) +1. [IBM: Continuous Delivery vs Continuous Deployment - Video](https://www.youtube.com/watch?v=igwFj8PPSnw) +1. [Amazon: Transition to Continuous Delivery - Video](https://www.youtube.com/watch?v=esEFaY0FDKc) +1. See **[Integrations](#integrations)** for integrations with other CI services. + +#### 2.4. Workflow + +1. [GitLab Flow - Video](https://youtu.be/enMumwvLAug?list=PLFGfElNsQthZnwMUFi6rqkyUZkI00OxIV) +1. [GitLab Flow vs Forking in GitLab - Video](https://www.youtube.com/watch?v=UGotqAUACZA) +1. [GitLab Flow Overview](https://about.gitlab.com/2014/09/29/gitlab-flow/) +1. [Always Start with an Issue](https://about.gitlab.com/2016/03/03/start-with-an-issue/) +1. [GitLab Flow Documentation](http://doc.gitlab.com/ee/workflow/gitlab_flow.html) + +#### 2.5. GitLab Comparisons + +1. [GitLab Compared to Other Tools](https://about.gitlab.com/comparison/) +1. [Comparing GitLab Terminology](https://about.gitlab.com/2016/01/27/comparing-terms-gitlab-github-bitbucket/) +1. [GitLab Compared to Atlassian (Recording 2016-03-03) ](https://youtu.be/Nbzp1t45ERo) +1. [GitLab Position FAQ](https://about.gitlab.com/handbook/positioning-faq) +1. [Customer review of GitLab with points on why they prefer GitLab](https://www.enovate.co.uk/web-design-blog/2015/11/25/gitlab-review/) + +--- + +### 3. <a name="advanced"></a> GitLab Advanced + +#### 3.1. Dev Ops + +1. [Xebia Labs: Dev Ops Terminology](https://xebialabs.com/glossary/) +1. [Xebia Labs: Periodic Table of DevOps Tools](https://xebialabs.com/periodic-table-of-devops-tools/) +1. [Puppet Labs: State of Dev Ops 2015 - Book](https://puppetlabs.com/sites/default/files/2015-state-of-devops-report.pdf) + +#### 3.2. Installing GitLab with Omnibus + +1. [What is Omnibus - Video](https://www.youtube.com/watch?v=XTmpKudd-Oo) +1. [How to Install GitLab with Omnibus - Video](https://www.youtube.com/watch?v=Q69YaOjqNhg) +1. [Installing GitLab - Online Course](https://courses.platzi.com/classes/git-gitlab/concepto/part-1/part-3/material/) +1. [Using a Non-Packaged PostgreSQL Database](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/README.md#using-a-non-packaged-postgresql-database-management-server) +1. [Using a MySQL Database](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/README.md#using-a-mysql-database-management-server-enterprise-edition-only) +1. [Installing GitLab on Microsoft Azure](https://about.gitlab.com/2016/07/13/how-to-setup-a-gitlab-instance-on-microsoft-azure/) +1. [Installing GitLab on Digital Ocean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/) + +#### 3.3. Permissions + +1. [How to Manage Permissions in GitLab EE - Video](https://www.youtube.com/watch?v=DjUoIrkiNuM) + +#### 3.4. Large Files + +1. [Big files in Git (Git LFS, Annex) - Video](https://www.youtube.com/watch?v=DawznUxYDe4) + +#### 3.5. LDAP and Active Directory + +1. [How to Manage LDAP, Active Directory in GitLab - Video](https://www.youtube.com/watch?v=HPMjM-14qa8) + +#### 3.6 Custom Languages + +1. [How to add Syntax Highlighting Support for Custom Langauges to GitLab - Video](how to add support for your favorite language to GitLab) + +#### 3.7. Scalability and High Availability + +1. [Scalability and High Availability - Video](https://www.youtube.com/watch?v=cXRMJJb6sp4&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=2) +1. [High Availability - Video](https://www.youtube.com/watch?v=36KS808u6bE&index=15&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) +1. [High Availability Documentation](https://about.gitlab.com/high-availability/) + +#### 3.8 Cycle Analytics + +1. [GitLab Cycle Analytics Overview](https://about.gitlab.com/2016/09/21/cycle-analytics-feature-highlight/) +1. [GitLab Cycle Analytics - Product Page](https://about.gitlab.com/solutions/cycle-analytics/) + +#### 3.9. <a name="integrations"></a> Integrations + +1. [How to Integrate JIRA and Jenkins with GitLab - Video](https://gitlabmeetings.webex.com/gitlabmeetings/ldr.php?RCID=44b548147a67ab4d8a62274047146415) +1. [How to Integrate Jira with GitLab](http://doc.gitlab.com/ee/integration/jira.html) +1. [How to Integrate Jenkins with GitLab](http://doc.gitlab.com/ee/integration/jenkins.html) +1. [How to Integrate Bamboo with GitLab](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/project_services/bamboo.md) +1. [How to Integrate Slack with GitLab](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/integration/slack.md) +1. [How to Integrate Convox with GitLab](https://about.gitlab.com/2016/06/09/continuous-delivery-with-gitlab-and-convox/) +1. [Getting Started with GitLab and Shippable CI](https://about.gitlab.com/2016/05/05/getting-started-gitlab-and-shippable/) + +--- + +## 4. <a name="external"></a> External Articles + +1. [2011 WSJ article by Mark Andreeson - Software is Eating the World](http://www.wsj.com/articles/SB10001424053111903480904576512250915629460) +1. [2014 Blog post by Chris Dixon - Software eats software development](http://cdixon.org/2014/04/13/software-eats-software-development/) +1. [2015 Venture Beat article - Actually, Open Source is Eating the World](http://venturebeat.com/2015/12/06/its-actually-open-source-software-thats-eating-the-world/) + +--- + +## 5. <a name="team"></a> Resources for GitLab Team Members + +*Some content can only be accessed by GitLab team members* + +1. [Support Path](support/README.md) +1. [Sales Path (redirect to sales handbook)](https://about.gitlab.com/handbook/sales-onboarding/) +1. [GitLab architecture for noobs](https://dev.gitlab.org/gitlab/gitlabhq/blob/master/doc/development/architecture.md) +1. [Client Assessment of GitLab versus GitHub](https://docs.google.com/a/gitlab.com/spreadsheets/d/18cRF9Y5I6I7Z_ab6qhBEW55YpEMyU4PitZYjomVHM-M/edit?usp=sharing) diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md new file mode 100644 index 00000000000..a86ff165f2e --- /dev/null +++ b/doc/university/glossary/README.md @@ -0,0 +1,482 @@ + +## What is the Glossary + +This contains a simplified list and definitions of some of the terms that you will encounter in your day to day activities when working with GitLab. +Please add any terms that you discover that you think would be useful for others. + +### 2FA + +User authentication by combination of 2 different steps during login. This allows for more security. + +### Access Levels + +Process of selective restriction to create, view, modify or delete a resource based on a set of assigned permissions. +See, [GitLab's Permission Guidelines](http://doc.gitlab.com/ce/permissions/permissions.html) + +### Active Directory (AD) + +A Microsoft based directory service for windows domain networks. It uses LDAP technology under the hood + +### Agile + +Building and delivering software in phases/parts rather than trying to build everything at once then delivering to the user/client. The later is known as a WaterFall model + +### Application Lifecycle Management (ALM) + +Entire product lifecycle management process for an application. From requirements management, development and testing until deployment. + +### Artifactory + +Version control for binaries. + +### Artifacts + +objects (usually binary and large) created by a build process + +### Atlassian + +A company that develops software products for developers and project managers including Bitbucket, Jira, Hipchat, Confluence, Bamboo. See [Atlassian] (https://www.atlassian.com) + +### Audit Log + +*** Needs definition here + +### Auto Defined User Group + +User groups are a way of centralizing control over important management tasks, particularly access control and password policies. +A simple example of such groups are the users and the admins groups. +In most of the cases these groups are auto defined in terms of access, rules of usage, conditions to be part of, etc... + +### Bamboo + +Atlassian's CI tool similar to GitLab CI and Jenkins + +### Basic Subscription + +Entry level subscription for GitLab EE currently available in packs of 10 see [Basic subscription](https://about.gitlab.com/pricing/) + +### Bitbucket + +Atlassian's web hosting service for Git and Mercurial Projects i.e. GitLab.com competitor + +### Branch + +A branch is a parallel version of a repository. Allows you to work on the repository without you affecting the "master" branch. Allows you to make changes without affecting the current "live" version. When you have made all your changes to your branch you can then merge to the master and to make the changes fo "live". + +### Branded Login + +Having your own logo on your GitLab instance login page instead of the GitLab logo. + +### CEPH + +is a distributed object store and file system designed to provide excellent performance, reliability and scalability. + +### Clone + +A copy of a repository stored on your machine that allows you to use your own editor without being online, but still tracks the changes made remotely. + +### Code Review + +Examination of a progam's code. The main aim is to maintain high standards quality of code that is being shipped. + +### Code Snippet + +A small amount of code. Usually for the purpose of showing other developers how +to do something specific or reproduce a problem. + +### Collaborator + +Person with read and write access to a repository who has been invited by repository owner. + +### Commit + +Is a change (revision) to a file, and also creates an ID that allows you to see revision history and who made the changes. + +### Community + +Everyone who is using GitLab + +### Confluence + +Atlassian's product for collaboration of documents and projects. + +### Continuous Deivery + +Continuous delivery is a series of practices designed to ensure that code can be rapidly and safely deployed to production by delivering every change to a production-like environment and ensuring business applications and services function as expected through rigorous automated testing. + +### Continuous Deployment + +Continuous deployment is the next step of continuous delivery: Every change that passes the automated tests is deployed to production automatically. + +### Continuous Integration + +A process that involves adding new code commits to source code with the combined code being run on an automated test to ensure that the changes do not break the software. + +### Contributor + +Term used to a person contributing to an Open Source Project. + +### Data Centre + +Atlassian product for High Availability. + +### Deploy Keys + +An SSH key stored on the your server that grants access to a single GitLab repository. This is used by a GitLab runner to clone a project's code so that tests can be run against the checked out code. + +### Developer + +For us (GitLab) this means a software developer, i.e. someone who makes software. It is also one of the levels of access in our multi level approval system. + +### Diff + +Is the difference between two commits, or saved changes. This will also be shown visually after the changes. + +### Docker + +Containers wrap up a piece of software in a complete filesystem that contains everything it needs to run: code, runtime, system tools, system libraries – anything you can install on a server. +This guarantees that it will always run the same, regardless of the environment it is running in. + +### Fork + +Your own copy of a repository that allows you to make changes to the repository without affecting the original. + +### Gerrit + +A code review tool built on top of Git. + +### Git Hooks + +Are scripts you can use to trigger actions at certain points. + +### GitHost.io + +Is a single-tenant solution that provides GitLab CE or EE as a managed service. GitLab Inc. is responsible for +installing, updating, hosting, and backing up customers own private and secure GitLab instance. + +### GitHub + +A web-based Git repository hosting service with an enterprise offering. Its main features are: issue tracking, pull request with code review, abundancy of integrations and wiki. As of April 2016, the service has over 14 million users. It offers free public repos, private repos and enterprise services are paid. + +### GitLab CE + +Our free on Premise solution with >100,000 users + +### GitLab CI + +Our own Continuos Integration feature that is shipped with each instance + +### GitLab EE + +Our premium on premise solution that currently has Basic, Standard and Plus subscription packages with additional features and support. + +### GitLab.com + +Our free SaaS for public and private repositories. + +### Gitolite + +Is basically an access layer that sits on top of Git. Users are granted access to repos via a simple config file and you as an admin only needs the users public SSH key and a username from the user. + +### Gitorious + +A web based hosting service for projects using Git. It was acquired by GitLab and we discontinued the service. [Gitorious Acquisition Blog Post](https://about.gitlab.com/2015/03/03/gitlab-acquires-gitorious/) + +### HADR + +Sometimes written HA/DR. High Availability for Disaster Recovery. Usually refers to a strategy having a failover server in place in case the main server fails. + +### Hip Chat + +Atlassian's real time chat application for teams. Competitor to Slack, RocketChat and MatterMost. + +### High Availability + +Refers to a system or component that is continuously operational for a desirably long length of time. Availability can be measured relative to "100% operational" or "never failing." + +### Issue Tracker + +A tool used to manage, organize, and maintain a list of issues, making it easier for an organization to manage. + +### Jenkins + +An Open Source CI tool written using the Java programming language. Does the same job as GitLab CI, Bamboo, Travis CI. It is extremely popular. see [Jenkins](https://jenkins-ci.org/) + +### Jira + +Atlassian's project management software. i.e. a complex issue tracker. See[Jira](https://www.atlassian.com/software/jira) + +### Kerberos + +A network authentication protocol that uses secret-key cryptography for security. + +### Kubernetes + +An open source container cluster manager originally designed by Google. It's basically a platform for automating deployment, scaling, and operations of application containers over clusters of hosts. + +### Labels + +An identifier to describe a group of one or more specific file revisions + +### LDAP + +Lightweight Directory Access Protocol - basically its a directory (electronic address book) with user information e.g. name, phone_number etc + +### LDAP User Authentication + +Allowing GitLab to sign in people from an LDAP server i.e. Allow people whose names are on the electronic user directory server) to be able to use their LDAP accounts to login. + +### LDAP Group Sync + +Allows you to synchronize the members of a GitLab group with one or more LDAP groups. + +### Git LFS + +Git Large File Storage. A way to enable git to handle large binary files by using reference pointers within small text files to point to the large files. + +### Linux + +An operating system like Windows or OS X. It is mostly used by software developers and on servers. + +### Markdown + +Is a lightweight markup language with plain text formatting syntax designed so that it can be converted to HTML and many other formats using a tool by the same name. +Markdown is often used to format readme files, for writing messages in online discussion forums, and to create rich text using a plain text editor. + +### Maria DB + +A community developed fork/variation of MySQL. MySQL is owned by Oracle. + +### Master + +Name of the default branch in every git repository. + +### Mercurial + +A free distributed version control system like Git. Think of it as a competitor to Git. + +### Merge + +Takes changes from one branch, and applies them into another branch. + +### Meteor + +A hip platform for building javascript apps.[Meteor] (https://www.meteor.com) + +### Milestones + +Allows you to track the progress on issues, and merge requests, which allows you to get a snapshot of the progress made. + +### Mirror Repositories + +You can set up a project to automatically have its branches, tags, and commits updated from an upstream repository. This is useful when a repository you're interested in is located on a different server, and you want to be able to browse its content and its activity using the familiar GitLab interface. + +### MIT License + +A type of software license. It lets people do anything with your code with proper attribution and without warranty. It is the most common license for open source applications written in Ruby on Rails. GitLab CE is issued under this license. +This means, you can download the code, modify it as you want even build a new commercial product using the underlying code and its not illegal. The only condition is that there is no form of waranty provided by GitLab so whatever happens if you use the code is your own problem. + +### Mondo + +*** Needs definition here + +### Multi LDAP Server + +*** Needs definition here + +### My SQL + +A relational database. Currently only supported if you are using EE. It is owned by Oracle. + +### Namespace + +In computing, a namespace is a set of symbols that are used to organize objects of various kinds, so that these objects may be referred to by name. + +Prominent examples include: +- file systems are namespaces that assign names to files; +- programming languages organize their variables and subroutines in namespaces; +- computer networks and distributed systems assign names to resources, such as computers, printers, websites, (remote) files, etc. + +### Nginx + +(pronounced "engine x") is a web server. It can act as a reverse proxy server for HTTP, HTTPS, SMTP, POP3, and IMAP protocols, as well as a load balancer and an HTTP cache. + +### oAuth + +Is an open standard for authorization, commonly used as a way for Internet users to log into third party websites using their Microsoft, Google, Facebook or Twitter accounts without exposing their password. + +### Omnibus Packages + +Omnibus is a way to package the different services and tools required to run GitLab, so that users can install it without as much work. + +### On Premise + +On your own server. In GitLab, this refers to the ability to download GitLab EE/GitLab CE and host it on your own server rather than using GitLab.com which is hosted by GitLab Inc's servers. + +### Open Source Software + +Software for which the original source code is freely available and may be redistributed and modified. + +### Owner + +This is the most powerful person on a GitLab project. He has the permissions of all the other users plus the additional permission of being able to destroy i.e. delete the project + +### PaaS + +Typically referred to in regards to application development, it is a model in which a cloud provider delivers hardware and software tools to its users as a service + +### Perforce + +The company that produces Helix. A commercial, proprietary, centralised VCS well known for it's ability to version files of any size and type. They OEM a re-branded version of GitLab called "GitSwarm" that is tightly integrated with their "GitFusion" product, which in turn represents a portion of a Helix repository (called a depot) as a git repo + +### Phabricator + +Is a suite of web-based software development collaboration tools, including the Differential code review tool, the Diffusion repository browser, the Herald change monitoring tool, the Maniphest bug tracker and the Phriction wiki. Phabricator integrates with Git, Mercurial, and Subversion. + +### Piwik Analytics + +An open source analytics software to help you analyze web traffic. It is similar to google analytics only that google analytics is not open source and information is stored by google while in Piwik the information is stored in your own server hence fully private. + +### Plus Subscription + +GitLab Premium EE subscription that includes training and dedicated Account Management and Service Engineer and complete support package [Plus subscription](https://about.gitlab.com/pricing/) + +### PostgreSQL + +A relational database. Touted as the most advanced open source database. + +### Protected Branches + +A feature that protects branches from unauthorized pushes, force pushing or deletion. + +### Pull + +Git command to synchronize the local repository with the remote repository, by fetching all remote changes and merging them into the local repository. + +### Puppet + +A popular devops automation tool + +### Push + +Git command to send commits from the local repository to the remote repository. + +### RE Read Only + +Permissions to see a file and it's contents, but not change it + +### Rebase + +Moves a branch from one commit to another. This allows you to re-write your project's history. + +### Git Repository + +Storage location of all files which are tracked by git. + +### Requirements management + +*** Needs definition here + +### Revision + +*** Needs definition here + +### Revision Control + +Also known as version control or source control, is the management of changes to documents, computer programs, large web sites, and other collections of information. Changes are usually identified by a number or letter code, termed the "revision number", "revision level", or simply "revision". + +### RocketChat + +An open source chat application for teams. Very similar to Slack only that is is open-source. + +### Runners + +Actual build machines/containers that run/execute tests you have specified to be run on GitLab CI + +### SaaS + +Software as a service. Software is hosted centrally and accessed on-demand i.e. when you want to. This refers to GitLab.com in our scenario + +### SCM + +Software Configuration Management. Often used by people when they mean Version Control + +## Scrum + +An Agile framework designed to help complete complex (typically) software projects. It's made up of several parts: product requirments backlog, sprint plannnig, sprint (development), sprint review, retrospec (analyzing the sprint). The goal is to end up with potentially shippable products. + +### Scrum Board + +The board used to track the status and progress of each of the sprint backlog items. + +### Slack + +Real time messaging app for teams. Used internally by GitLab + +### Slave Servers + +Also known as secondary servers. They help to spread the load over multiple machines, they also provide backups when the master/primary server crashes. + +### Source Code + +Program code as typed by a computer programmer. i.e. it has not yet been compiled/translated by the computer to machine language. + +### SSH Key + +A unique identifier of a computer. It is used to identify computers without the need for a password. e.g. On GitLab I have added the ssh key of all my work machines so that the GitLab instance knows that it can accept code pushes and pulls from this trusted machines whose keys are I have added. + +### SSO + +Single Sign On. An authentication process that allows you enter one username and password to access multiple applications. + +### Standard Subscription + +Our mid range EE subscription that includes 24/7 support, support for High Availability [Standard Subscription](https://about.gitlab.com/pricing/) + +### Stash + +Atlassian's Git On-Premises solution. Think of it as Atlassian's GitLab EE. It is now known as BitBucket Server. + +### Subversion + +Non-proprietary, centralized version control system. + +### Sudo + +A program that allows you to perform superuser/administrator actions on Unix Operating Systems e.g. Linux, OS X. It actually stands for 'superuser do' + +### SVN + +Abbreviation for Subversion. + +### Tag + +Represents a version of a particular branch at a moment in time. + +### Tool Stack + +Set of tools used in a process to achieve a common outcome. E.g. set of tools used in Application Lifecycle Management. + +### Trac + +An Open Source project management and bug tracking web application. + +### User + +Anyone interacting with the software. + +### VCS + +Version Control Software + +### Waterfall + +A model of building software that involves collecting all requirements from the customer, then building and refining all the requirements and finally delivering the COMPLETE software to the customer that meets all the requirements specified by the customer + +### Webhooks + +A way for for an app to provide other applications with real-time information. e.g. send a message to a slack channel when a commit is pushed + +### Wiki + +A website/system that allows for collaborative editing of its content by the users. In programming, they usually contain documentation of how to use the software diff --git a/doc/university/high-availability/aws/README.md b/doc/university/high-availability/aws/README.md new file mode 100644 index 00000000000..088f1cd7290 --- /dev/null +++ b/doc/university/high-availability/aws/README.md @@ -0,0 +1,387 @@ + +# High Availability on AWS + +GitLab on AWS can leverage many of the services that are already +configurable with High Availability. These services have a lot of +flexibility and are able to adopt to most companies, best of all is the +ability to automate both vertical and horizontal scaling. + +In this article we'll go through a basic HA setup where we'll start by +configuring our Virtual Private Cloud and subnets to later integrate +services such as RDS for our database server and ElastiCache as a Redis +cluster to finally manage them within an auto scaling group with custom +scaling policies. + +*** + +## Where to Start + +Login to your AWS account through the `My Account` dropdown on +`https://aws.amazon.com` or through the URI assigned to your team such as +`https://myteam.signin.aws.amazon.com/console/`. You'll start on the +Amazon Web Services console from where we can choose all of the services +we'll be using to configure our cloud infrastructure. + +*** + +## Network + +We'll start by creating a VPC for our GitLab cloud infrastructure, then +we can create subnets to have public and private instances in at least +two AZs. Public subnets will require a Route Table keep an associated +Internet Gateway. + +### VPC + +Start by looking for the VPC option on the web console. Now create a new +VPC. We can use `10.0.0.0/16` for the CIDR block and leave tenancy as +default if we don't require dedicated hardware. + + + +If you're setting up the Elastic File System service then select the VPC +and from the Actions dropdown choose Edit DNS Hostnames and select Yes. + +### Subnet + +Now let's create some subnets in different Availability Zones. Make sure +that each subnet is associated the the VPC we just created, that it has +a distinct VPC and lastly that CIDR blocks don't overlap. This will also +allow us to enable multi AZ for redundancy. + +We will create private and public subnets to match load balancers and +RDS instances as well. + + + +The subnets are listed with their name, AZ and CIDR block: + +* gitlab-public-10.0.0.0 - us-west-2a - 10.0.0.0 +* gitlab-private-10.0.1.0 - us-west-2a - 10.0.1.0 +* gitlab-public-10.0.2.0 - us-west-2b - 10.0.2.0 +* gitlab-private-10.0.3.0 - us-west-2b - 10.0.3.0 + +### Route Table + +Up to now all our subnets are private. We need to create a Route Table +to associate an Internet Gateway. On the same VPC dashboard choose +Route Tables on the left column and give it a name and associate it to +our newly created VPC. + + + + +### Internet Gateway + +Now still on the same dashboard head over to Internet Gateways and +create a new one. After its created pres on the `Attach to VPC` button and +select our VPC. + + + +### Configure Subnets + +Go back to the Router Tables screen and select the newly created one, +press the Routes tab on the bottom section and edit it. We need to add a +new target which will be our Internet Gateway and have it receive +traffic from any destination. + + + +Before leaving this screen select the next tab to the rgiht which is +Subnet Associations and add our public subnets. If you followed our +naming convention they should be easy to find. + +*** + +## Database with RDS + +For our database server we will use Amazon RDS which offers Multi AZ +for redundancy. Lets start by creating a subnet group and then we'll +create the actual RDS instance. + +### Subnet Group + +From the RDS dashboard select Subnet Groups. Lets select our VPC from +the VPC ID dropdown and at the bottom we can add our private subnets. + + + +### RDS + +Select the RDS service from the Database section and create a new +PostgreSQL instance. After choosing between a Production or +Development instance we'll start with the actual configuration. On the +image bellow we have the settings for this article but note the +following two options which are of particular interest for HA: + +1. Multi-AZ-Deployment is recommended as redundancy. Read more at +[High Availability (Multi-AZ)](http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.html) +1. While we chose a General Purpose (SSD) for this article a Provisioned +IOPS (SSD) is best suited for HA. Read more about it at +[Storage for Amazon RDS](http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html) + + + +The rest of the setting on this page request a DB identifier, username +and a master password. We've chosen to use `gitlab-ha`, `gitlab` and a +very secure password respectively. Keep these in hand for later. + + + +Make sure to choose our gitlab VPC, our subnet group, not have it public, +and to leave it to create a new security group. The only additional +change which will be helpful is the database name for which we can use +`gitlabhq_production`. + +*** + +## ElastiCache + +EC is an in-memory hosted caching solution. Redis maintains its own +persistance and is used for certain types of application. + +Let's choose the ElastiCache service in the Database section from our +AWS console. Now lets create a cache subnet group which will be very +similar to the RDS subnet group. Make sure to select our VPC and its +private subnets. + + + +Now press the Launch a Cache Cluster and choose Redis for our +DB engine. You'll be able to configure details such as replication, +Multi AZ and node types. The second section will allow us to choose our +subnet and security group and + + + + + +*** + +## Elastic File System + +This new AWS offering allows us to create a file system accessible by
+EC2 instances within a VPC. Choose our VPC and the subnets will be +
automatically configured assuming we don't need to set explicit IPs. +The
next section allows us to add tags and choose between General +Purpose or
Max I/O which is a good option when being accessed by a +large number of
EC2 instances. + +
 + +To actually mount and install the NFS client we'll use the User Data +section when adding our Launch Configuration. + +*** + +## Initiate AMI + +We are going to launch an EC2 instance and bake an image so that we can +later use it for auto scaling. We'll also take this opportunity to add an +extension to our RDS through this temporary EC2 instance. + +### EC2 Instance + +Look for the EC2 option and choose to create an instance. We'll need at +least a t2.medium type and for this article we'll choose an Ubuntu 14.04 +HVM 64-bit. In the Configure Instance section choose our GitLab VPC and +a public subnet. I'd choose at least 10GB of storage. + +In the security group we'll create a new one considering that we need to +SSH into the instance and also try it out through http. So let's add the +http traffic from anywhere and name it something such as +`gitlab-ec2-security-group`. + +While we wait for it to launch we can allocate an Elastic IP and +associate it with our new EC2 instance. + +### RDS and Redis Security Group + +After the instance is being created we will navigate to our EC2 security +groups and add a small change for our EC2 instances to be able to +connect to RDS. First copy the security group name we just defined, +namely `gitlab-ec2-security-group`, and edit select the RDS security +group and edit the inbound rules. Choose the rule type to be PostgreSQL +and paste the name under source. + + + +Similar to the above we'll jump to the `gitlab-ec2-security-group` group +and add a custom TCP rule for port 6379 accessible within itself. + +### Install GitLab + +To connect through SSH you will need to have the `pem` file which you +chose available and with the correct permissions such as `400`. + +After accessing your server don't forget to update and upgrade your +packages. + + sudo apt-get update && sudo apt-get upgrade -y + +Then follow installation instructions from +[GitLab](https://about.gitlab.com/downloads-ee/#ubuntu1404), but before +running reconfigure we need to make sure all our services are tied down +so just leave the reconfigure command until after we edit our gitlab.rb +file. + + +### Extension for PostgreSQL + +Connect to your new RDS instance to verify access and to install +a required extension. We can find the host or endpoint by selecting the +instance and we just created and after the details drop down we'll find +it labeled as 'Endpoint'; do remember not to include the colon and port +number. + + sudo /opt/gitlab/embedded/bin/psql -U gitlab -h <rds-endpoint> -d gitlabhq_production + psql (9.4.7) + Type "help" for help. + + gitlab=# CREATE EXTENSION pg_trgm; + gitlab=# \q + +### Configure GitLab + +While connected to your server edit the `gitlab.rb` file at `/etc/gitlab/gitlab.rb` +find the `external_url 'http://gitlab.example.com'` option and change it +to the domain you will be using or the public IP address of the current +instance to test the configuration. + +For a more detailed description about configuring GitLab read [Configuring GitLab for HA](http://docs.gitlab.com/ee/administration/high_availability/gitlab.html) + +Now look for the GitLab database settings and uncomment as necessary. In +our current case we'll specify the adapter, encoding, host, db name, +username, and password. + + gitlab_rails['db_adapter'] = "postgresql" + gitlab_rails['db_encoding'] = "unicode" + gitlab_rails['db_database'] = "gitlabhq_production" + gitlab_rails['db_username'] = "gitlab" + gitlab_rails['db_password'] = "mypassword" + gitlab_rails['db_host'] = "<rds-endpoint>" + +Next we only need to configure the Redis section by adding the host and +uncommenting the port. + + + +The last configuration step is to [change the default file locations ](http://docs.gitlab.com/ee/administration/high_availability/nfs.html) +to make the EFS integration easier to manage. + + gitlab_rails['redis_host'] = "<redis-endpoint>" + gitlab_rails['redis_port'] = 6379 + +Finally run reconfigure, you might find it useful to run a check and +a service status to make sure everything has been setup correctly. + + sudo gitlab-ctl reconfigure + sudo gitlab-rake gitlab:check + sudo gitlab-ctl status + +If everything looks good copy the Elastic IP over to your browser and +test the instance manually. + +### AMI + +After you finish testing your EC2 instance go back to its dashboard and +while the instance is selected press on the Actions dropdown to choose +Image -> Create an Image. Give it a name and description and confirm. + +*** + +## Load Balancer + +On the same dashboard look for Load Balancer on the left column and press +the Create button. Choose a classic Load Balancer, our gitlab VPC, not +internal and make sure its listening for HTTP and HTTPS on port 80. + +Here is a tricky part though, when adding subnets we need to associate +public subnets instead of the private ones where our instances will +actually live. + +On the secruity group section let's create a new one named +`gitlab-loadbalancer-sec-group` and allow both HTTP ad HTTPS traffic +from anywhere. + +The Load Balancer Health will allow us to indicate where to ping and what +makes up a healthy or unhealthy instance. + +We won't add the instance on the next session because we'll destroy it +momentarily as we'll be using the image we where creating. We will keep +the Enable Cross-Zone and Enable Connection Draining active. + +After we finish creating the Load Balancer we can re visit our Security +Groups to improve access only through the ELB and any other requirement +you might have. + +*** + +## Auto Scaling Group + +Our AMI should be done by now so we can start working on our Auto +Scaling Group. + +This option is also available through the EC2 dashboard on the left +sidebar. Press on the create button. Select the new image on My AMIs and +give it a `t2.medium` size. To be able to use Elastic File System we need +to add a script to mount EFS automatically at launch. We'll do this at +the Advanced Details section where we have a [User Data](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html) +text area that allows us to add a lot of custom configurations which +allows you to add a custom script for when launching an instance. Let's +add the following script to the User Data section: + + + #cloud-config + package_upgrade: true + packages: + - nfs-common + runcmd: + - mkdir -p /gitlab-data + - chown ec2-user:ec2-user /gitlab-data + - echo "$(curl --silent http://169.254.169.254/latest/meta-data/placement/availability-zone).file-system-id.aws-region.amazonaws.com:/ /gitlab-data nfs defaults,vers=4.1 0 0" >> /etc/fstab + - mount -a -t nfs + - sudo gitlab-ctl reconfigure + +On the security group section we can chosse our existing +`gitlab-ec2-security-group` group which has already been tested. + +After this is launched we are able to start creating our Auto Scaling +Group. Start by giving it a name and assinging it our VPC and private +subnets. We also want to always start with two instances and if you +scroll down to Advanced Details we can choose to receive traffic from ELBs. +Lets enable that option and select our ELB. We also want to use the ELB's +health check. + + + +### Policies + +This is the really great part of Auto Scaling, we get to choose when AWS +launches new instances and when it removes them. For this group we'll +scale between 2 and 4 instances where one instance will be added if CPU +utilization is greater than 60% and one instance is removed if it falls +to less than 45%. Here are the complete policies: + + + +You'll notice that after we save this AWS starts launching our two +instances in different AZs and without a public IP which is exactly what +we where aiming for. + +*** + +## Final Thoughts + +After you're done with the policies section have some fun trying to break +instances. You should be able to see how the Auto Scaling Group and the +EC2 screen start bringing them up again. + +High Availability is a very big area, we went mostly through scaling and +some redundancy options but it might also imply Geographic replication. +There is a lot of ground yet to cover so have a read through these other +resources and feel free to open an issue to request additional material. + + * [GitLab High Availability](http://docs.gitlab.com/ce/administration/high_availability/README.html#sts=High Availability) + * [GitLab Geo](http://docs.gitlab.com/ee/gitlab-geo/README.html) diff --git a/doc/university/high-availability/aws/img/auto-scaling-det.png b/doc/university/high-availability/aws/img/auto-scaling-det.png Binary files differnew file mode 100644 index 00000000000..e9b65529495 --- /dev/null +++ b/doc/university/high-availability/aws/img/auto-scaling-det.png diff --git a/doc/university/high-availability/aws/img/db-subnet-group.png b/doc/university/high-availability/aws/img/db-subnet-group.png Binary files differnew file mode 100644 index 00000000000..0768aa73c45 --- /dev/null +++ b/doc/university/high-availability/aws/img/db-subnet-group.png diff --git a/doc/university/high-availability/aws/img/ec-subnet.png b/doc/university/high-availability/aws/img/ec-subnet.png Binary files differnew file mode 100644 index 00000000000..f41d78b271d --- /dev/null +++ b/doc/university/high-availability/aws/img/ec-subnet.png diff --git a/doc/university/high-availability/aws/img/elastic-file-system.png b/doc/university/high-availability/aws/img/elastic-file-system.png Binary files differnew file mode 100644 index 00000000000..7de866d1e89 --- /dev/null +++ b/doc/university/high-availability/aws/img/elastic-file-system.png diff --git a/doc/university/high-availability/aws/img/ig-rt.png b/doc/university/high-availability/aws/img/ig-rt.png Binary files differnew file mode 100644 index 00000000000..93bb0c2ae02 --- /dev/null +++ b/doc/university/high-availability/aws/img/ig-rt.png diff --git a/doc/university/high-availability/aws/img/ig.png b/doc/university/high-availability/aws/img/ig.png Binary files differnew file mode 100644 index 00000000000..cc50456370f --- /dev/null +++ b/doc/university/high-availability/aws/img/ig.png diff --git a/doc/university/high-availability/aws/img/instance_specs.png b/doc/university/high-availability/aws/img/instance_specs.png Binary files differnew file mode 100644 index 00000000000..ef31dc41dae --- /dev/null +++ b/doc/university/high-availability/aws/img/instance_specs.png diff --git a/doc/university/high-availability/aws/img/new_vpc.png b/doc/university/high-availability/aws/img/new_vpc.png Binary files differnew file mode 100644 index 00000000000..4aac6af7c7a --- /dev/null +++ b/doc/university/high-availability/aws/img/new_vpc.png diff --git a/doc/university/high-availability/aws/img/policies.png b/doc/university/high-availability/aws/img/policies.png Binary files differnew file mode 100644 index 00000000000..8c58117e4fa --- /dev/null +++ b/doc/university/high-availability/aws/img/policies.png diff --git a/doc/university/high-availability/aws/img/rds-net-opt.png b/doc/university/high-availability/aws/img/rds-net-opt.png Binary files differnew file mode 100644 index 00000000000..bc204de2474 --- /dev/null +++ b/doc/university/high-availability/aws/img/rds-net-opt.png diff --git a/doc/university/high-availability/aws/img/rds-sec-group.png b/doc/university/high-availability/aws/img/rds-sec-group.png Binary files differnew file mode 100644 index 00000000000..8864dc3e463 --- /dev/null +++ b/doc/university/high-availability/aws/img/rds-sec-group.png diff --git a/doc/university/high-availability/aws/img/redis-cluster-det.png b/doc/university/high-availability/aws/img/redis-cluster-det.png Binary files differnew file mode 100644 index 00000000000..9e9a81283c5 --- /dev/null +++ b/doc/university/high-availability/aws/img/redis-cluster-det.png diff --git a/doc/university/high-availability/aws/img/redis-net.png b/doc/university/high-availability/aws/img/redis-net.png Binary files differnew file mode 100644 index 00000000000..037bd6d6897 --- /dev/null +++ b/doc/university/high-availability/aws/img/redis-net.png diff --git a/doc/university/high-availability/aws/img/route_table.png b/doc/university/high-availability/aws/img/route_table.png Binary files differnew file mode 100644 index 00000000000..1dea322474d --- /dev/null +++ b/doc/university/high-availability/aws/img/route_table.png diff --git a/doc/university/high-availability/aws/img/subnet.png b/doc/university/high-availability/aws/img/subnet.png Binary files differnew file mode 100644 index 00000000000..dbc71201992 --- /dev/null +++ b/doc/university/high-availability/aws/img/subnet.png diff --git a/doc/university/process/README.md b/doc/university/process/README.md new file mode 100644 index 00000000000..7ff53c2cc3f --- /dev/null +++ b/doc/university/process/README.md @@ -0,0 +1,30 @@ +--- +title: University | Process +--- + +## Suggesting improvements + +If you would like to teach a class or participate or help in any way please +submit a merge request and assign it to [Job](https://gitlab.com/u/JobV). + +If you have suggestions for additional courses you would like to see, +please submit a merge request to add an upcoming class, assign to +[Chad](https://gitlab.com/u/chadmalchow) and /cc [Job](https://gitlab.com/u/JobV). + +## Adding classes + +1. All training materials of any kind should be added to [GitLab CE](https://gitlab.com/gitlab-org/gitlab-ce/) + to ensure they are available to a broad audience (don't use any other repo or + storage for training materials). +1. Don't make materials that are needlessly specific to one group of people, try + to keep the wording broad and inclusive (don't make things for only GitLab Inc. + people, only interns, only customers, etc.). +1. To allow people to contribute all content should be in git. +1. The content can go in a subdirectory under `/doc/university/`. +1. To make, view or modify the slides of the classes use [Deckset](http://www.decksetapp.com/) + or [RevealJS](http://lab.hakim.se/reveal-js/). Do not use Powerpoint or Google + Slides since this prevents everyone from contributing. +1. Please upload any video recordings to our Youtube channel. We prefer them to + be public, if needed they can be unlisted but if so they should be linked from + this page. +1. Please create a merge request and assign to [SeanPackham](https://gitlab.com/u/SeanPackham). diff --git a/doc/university/support/README.md b/doc/university/support/README.md new file mode 100644 index 00000000000..da991e56370 --- /dev/null +++ b/doc/university/support/README.md @@ -0,0 +1,188 @@ + +## Support Boot Camp + +**Goal:** Prepare new Service Engineers at GitLab + +For each stage there are learning goals and content to support the learning of the engineer. +The goal of this boot camp is to have every Service Engineer prepared to help our customers +with whatever needs they might have and to also assist our awesome community with their +questions. + +Always start with the [University Overview](../README.md) and then work +your way here for more advanced and specific training. Once you feel comfortable +with the topics of the current stage, move to the next. + +### Stage 1 + +Follow the topics on the [University Overview](../README.md), concentrate on it +during your first Stage, but also: + +- Perform the [first steps](https://about.gitlab.com/handbook/support/onboarding/#first-steps) of + the on-boarding process for new Service Engineers + +#### Goals + +Aim to have a good overview of the Product and main features, Git and the Company + +### Stage 2 + +Continue to look over remaining portions of the [University Overview](../README.md) and continue on to these topics: + +#### Set up your development machine + +Get your development machine ready to familiarize yourself with the codebase, the components, and to be prepared to reproduce issues that our users encounter + +- Install the [GDK](https://gitlab.com/gitlab-org/gitlab-development-kit) + - [Setup OpenLDAP as part of this](https://gitlab.com/gitlab-org/gitlab-development-kit#openldap) + +#### Become comfortable with the Installation processes that we support + +It's important to understand how to install GitLab in the same way that our users do. Try installing different versions and upgrading and downgrading between them. Installation from source will give you a greater understanding of the components that we employ and how everything fits together. + +Sometimes we need to upgrade customers from old versions of GitLab to latest, so it's good to get some experience of doing that now. + +- [Installation Methods](https://about.gitlab.com/installation/): + - [Omnibus](https://gitlab.com/gitlab-org/omnibus-gitlab/) + - [Docker](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/docker) + - [Source](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) +- Get yourself a Digital Ocean droplet, where you can install and maintain your own instance of GitLab + - Ask in #infrastructure about this + - Populate with some test data + - Keep this up-to-date as patch and version releases become available, just like our customers would +- Try out the following installation path + - [Install GitLab 4.2 from source](https://gitlab.com/gitlab-org/gitlab-ce/blob/d67117b5a185cfb15a1d7e749588ff981ffbf779/doc/install/installation.md) + - External MySQL database + - External NGINX + - Create some test data + - Populated Repos + - Users + - Groups + - Projects + - [Backup using our Backup rake task](http://docs.gitlab.com/ce/raketasks/backup_restore.html#create-a-backup-of-the-gitlab-system) + - [Upgrade to 5.0 source using our Upgrade documentation](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/doc/update/4.2-to-5.0.md) + - [Upgrade to 5.1 source](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/doc/update/5.0-to-5.1.md) + - [Upgrade to 6.0 source](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/doc/update/5.1-to-6.0.md) + - [Upgrade to 7.14 source](https://gitlab.com/gitlab-org/gitlab-ee/blob/master/doc/update/6.x-or-7.x-to-7.14.md) + - [Backup using our Backup rake task](http://docs.gitlab.com/ce/raketasks/backup_restore.html#create-a-backup-of-the-gitlab-system) + - [Perform the MySQL to PostgreSQL migration to convert your backup](http://docs.gitlab.com/ce/update/mysql_to_postgresql.html#converting-a-gitlab-backup-file-from-mysql-to-postgres) + - [Upgrade to Omnibus 7.14](http://doc.gitlab.com/omnibus/update/README.html#upgrading-from-a-non-omnibus-installation-to-an-omnibus-installation) + - [Restore backup using our Restore rake task](http://docs.gitlab.com/ce/raketasks/backup_restore.html#restore-a-previously-created-backup) + - [Upgrade to latest EE](https://about.gitlab.com/downloads-ee) + - (GitLab inc. only) Acquire and apply a license for the Enterprise Edition product, ask in #support +- Perform a downgrade from [EE to CE](http://doc.gitlab.com/ee/downgrade_ee_to_ce/README.html) + +#### Start to learn about some of the integrations that we support + +Our integrations add great value to GitLab. User questions often relate to integrating GitLab with existing external services and the configuration involved + +- Learn about our Integrations (specially, not only): + - [LDAP](http://doc.gitlab.com/ee/integration/ldap.html) + - [JIRA](http://doc.gitlab.com/ee/project_services/jira.html) + - [Jenkins](http://doc.gitlab.com/ee/integration/jenkins.html) + - [SAML](http://doc.gitlab.com/ce/integration/saml.html) + +#### Goals + +- Aim to be comfortable with installation of the GitLab product and configuration of some of the major integrations +- Aim to have an installation available for reproducing customer reports + +### Stage 3 + +#### Understand the gathering of diagnostics for GitLab instances + +- Learn about the GitLab checks that are available + - [Environment Information and maintenance checks](http://docs.gitlab.com/ce/raketasks/maintenance.html) + - [GitLab check](http://docs.gitlab.com/ce/raketasks/check.html) + - Omnibus commands + - [Status](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/maintenance/README.md#get-service-status) + - [Starting and stopping services](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/maintenance/README.md#starting-and-stopping) + - [Starting a rails console](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/maintenance/README.md#invoking-rake-tasks) + +#### Learn about the Support process + +Zendesk is our Support Centre and our main communication line with our Customers. We communicate with customers through several other channels too + +- Familiarize yourself with ZenDesk + - [UI Overview](https://support.zendesk.com/hc/en-us/articles/203661806-Introduction-to-the-Zendesk-agent-interface) + - [Updating Tickets](https://support.zendesk.com/hc/en-us/articles/212530318-Updating-and-solving-tickets) + - [Working w/ Tickets](https://support.zendesk.com/hc/en-us/articles/203690856-Working-with-tickets) *Read: avoiding agent collision.* +- Dive into our ZenDesk support process by reading how to [handle tickets](https://about.gitlab.com/handbook/support/onboarding/#handling-tickets) +- Start getting real world experience by handling real tickets, all the while gaining further experience with the Product. + - First, learn about our [Support Channels](https://about.gitlab.com/handbook/support/#support-channels) + - Ask other Service Engineers for help, when necessary, and to review your responses + - Start with [StackOverflow](https://about.gitlab.com/handbook/support/#stack-overflowa-namestack-overflowa) and the [GitLab forum](https://about.gitlab.com/handbook/support/#foruma-namegitlab-foruma) + - Here you will find a large variety of queries mainly from our Users who are self hosting GitLab CE + - Understand the questions that are asked and dig in to try to find a solution + - [Proceed on to the GitLab.com Support Forum](https://about.gitlab.com/handbook/support/#gitlabcom-support-trackera-namesupp-foruma) + - Here you will find queries regarding our own GitLab.com + - Helping Users here will give you an understanding of our Admin interface and other tools + - [Proceed on to the Twitter tickets in Zendesk](https://about.gitlab.com/handbook/support/#twitter) + - Here you will gain a great insight into our userbase + - Learn from any complaints and problems and feed them back to the team + - Tweets can range from help needed with GitLab installations, the API and just general queries + - [Proceed on to Regular email Support tickets](https://about.gitlab.com/handbook/support/#regular-zendesk-tickets-a-nameregulara) + - Here you will find tickets from our GitLab EE Customers and GitLab CE Users + - Tickets here are extremely varied and often very technical + - You should be prepared for these tickets, given the knowledge gained from previous tiers and your training +- Check out your colleagues' responses + - Hop on to the #support-live-feed channel in Slack and see the tickets as they come in and are updated + - Read through old tickets that your colleagues have worked on +- Start arranging to pair on calls with other Service Engineers. Aim to cover a few of each type of call + - [Learn about Cisco WebEx](https://about.gitlab.com/handbook/support/onboarding/#webexa-namewebexa) + - Training calls + - Information gathering calls + - It's good to find out how new and prospective customers are going to be using the product and how they will set up their infrastructure + - Diagnosis calls + - When email isn't enough we may need to hop on a call and do some debugging along side the customer + - These paired calls are a great learning experience + - Upgrade calls + - Emergency calls + +#### Learn about the Escalation process for tickets + +Some tickets need specific knowledge or a deep understanding of a particular component and will need to be escalated to a Senior Service Engineer or Developer + +- Read about [Escalation](https://about.gitlab.com/handbook/support/onboarding/#create-issuesa-namecreate-issuea) +- Find the macros in Zendesk for ticket escalations +- Take a look at the [GitLab.com Team page](https://about.gitlab.com/team/) to find the resident experts in their fields + +#### Learn about raising issues and fielding feature proposals + +- Understand what's in the pipeline and proposed features at GitLab: [Direction Page](https://about.gitlab.com/direction/) +- Practice searching issues and filtering using [labels](https://gitlab.com/gitlab-org/gitlab-ce/labels) to find existing feature proposals and bugs +- If raising a new issue always provide a relevant label and a link to the relevant ticket in Zendesk +- Add [customer labels](https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=customer) for those issues relevant to our subscribers +- Take a look at the [existing issue templates](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#issue-tracker) to see what is expected +- Raise issues for bugs in a manner that would make the issue easily reproducible. A Developer or a contributor may work on your issue + +#### Goals + +- Aim to have a good understanding of the problems that customers are facing +- Aim to have gained experience in scheduling and participating in calls with customers +- Aim to have a good understanding of ticket flow through Zendesk and how to interat with our various channels + +### Stage 4 + +#### Advanced GitLab topics + +Move on to understanding some of GitLab's more advanced features. You can make use of GitLab.com to understand the features from an end-user perspective and then use your own instance to understand setup and configuration of the feature from an Administrative perspective + +- Set up and try [Git Annex](http://doc.gitlab.com/ee/workflow/git_annex.html) +- Set up and try [Git LFS](http://doc.gitlab.com/ee/workflow/lfs/manage_large_binaries_with_git_lfs.html) +- Get to know the [GitLab API](http://doc.gitlab.com/ee/api/README.html), its capabilities and shortcomings +- Learn how to [migrate from SVN to Git](http://doc.gitlab.com/ee/workflow/importing/migrating_from_svn.html) +- Set up [GitLab CI](http://doc.gitlab.com/ee/ci/quick_start/README.html) +- Create your first [GitLab Page](http://doc.gitlab.com/ee/pages/administration.html) +- Get to know the GitLab Codebase by reading through the source code: + - Find the differences between the [EE codebase](https://gitlab.com/gitlab-org/gitlab-ce) + and the [CE codebase](https://gitlab.com/gitlab-org/gitlab-ce) +- Ask as many questions as you can think of on the `#support` chat channel + +#### Get initiated for on-call duty + +- Read over the [public run-books to understand common tasks](https://gitlab.com/gitlab-com/runbooks) +- Create an issue on the internal Organization tracker to schedule time with the DevOps / Production team, so that you learn how to handle GitLab.com going down. Once you are trained for this, you are ready to be added to the on-call rotation. + +#### Goals + +- Aim to become a fully-fledged Service Engineer! diff --git a/doc/university/training/end-user/README.md b/doc/university/training/end-user/README.md new file mode 100644 index 00000000000..03c62a81b10 --- /dev/null +++ b/doc/university/training/end-user/README.md @@ -0,0 +1,420 @@ + +# Training + +This training material is the markdown used to generate training slides +which can be found at [End User Slides](https://gitlab-org.gitlab.io/end-user-training-slides/#/) +through it's [RevealJS](https://gitlab.com/gitlab-org/end-user-training-slides) +project. + +--- + +## Git Intro + +--- + +### What is a Version Control System (VCS) + +- Records changes to a file +- Maintains history of changes +- Disaster Recovery +- Types of VCS: Local, Centralized and Distributed + +--- + +### Short Story of Git + +- 1991-2002: The Linux kernel was being maintaned by sharing archived files + and patches. +- 2002: The Linux kernel project began using a DVCS called BitKeeper +- 2005: BitKeeper revoked the free-of-charge status and Git was created + +--- + +### What is Git + +- Distributed Version Control System +- Great branching model that adapts well to most workflows +- Fast and reliable +- Keeps a complete history +- Disaster recovery friendly +- Open Source + +--- + +### Getting Help + +- Use the tools at your disposal when you get stuck. + - Use `git help <command>` command + - Use Google (i.e. StackOverflow, Google groups) + - Read documentation at https://git-scm.com + +--- + +## Git Setup +Workshop Time! + +--- + +### Setup + +- Windows: Install 'Git for Windows' + - https://git-for-windows.github.io +- Mac: Type `git` in the Terminal application. + - If it's not installed, it will prompt you to install it. +- Linux + - Debian: `sudo apt-get install git-all` + - Red Hat `sudo yum install git-all` + +--- + +### Configure + +- One-time configuration of the Git client: + +```bash +git config --global user.name "Your Name" +git config --global user.email you@example.com +``` + +- If you don't use the global flag you can setup a different author for + each project +- Check settings with: + +```bash +git config --global --list +``` +- You might want or be required to use an SSH key. + - Instructions: [SSH](http://doc.gitlab.com/ce/ssh/README.html) + +--- + +### Workspace + +- Choose a directory on you machine easy to access +- Create a workspace or development directory +- This is where we'll be working and adding content + +--- + +```bash +mkdir ~/development +cd ~/development + +-or- + +mkdir ~/workspace +cd ~/workspace +``` + +--- + +## Git Basics + +--- + +### Git Workflow + +- Untracked files + - New files that Git has not been told to track previously. +- Working area (Workspace) + - Files that have been modified but are not committed. +- Staging area (Index) + - Modified files that have been marked to go in the next commit. +- Upstream + - Hosted repository on a shared server + +--- + +### GitLab + +- GitLab is an application to code, test and deploy. +- Provides repository management with access controls, code reviews, + issue tracking, Merge Requests, and other features. +- The hosted version of GitLab is gitlab.com + +--- + +### New Project + +- Sign in into your gitlab.com account +- Create a project +- Choose to import from 'Any Repo by URL' and use https://gitlab.com/gitlab-org/training-examples.git +- On your machine clone the `training-examples` project + +--- + +### Git and GitLab basics + +1. Edit `edit_this_file.rb` in `training-examples` +2. See it listed as a changed file (working area) +3. View the differences +4. Stage the file +5. Commit +6. Push the commit to the remote +7. View the git log + +--- + +```shell +# Edit `edit_this_file.rb` +git status +git diff +git add <file> +git commit -m 'My change' +git push origin master +git log +``` + +--- + +### Feature Branching + +1. Create a new feature branch called `squash_some_bugs` +2. Edit `bugs.rb` and remove all the bugs. +3. Commit +4. Push + +--- + +```shell +git checkout -b squash_some_bugs +# Edit `bugs.rb` +git status +git add bugs.rb +git commit -m 'Fix some buggy code' +git push origin squash_some_bugs +``` + +--- + +## Merge Request + +--- + +### Merge requests + +- When you want feedback create a merge request +- Target is the ‘default’ branch (usually master) +- Assign or mention the person you would like to review +- Add `WIP` to the title if it's a work in progress +- When accepting, always delete the branch +- Anyone can comment, not just the assignee +- Push corrections to the same branch + + +--- + +### Merge request example + +- Create your first merge request + - Use the blue button in the activity feed + - View the diff (changes) and leave a comment + - Push a new commit to the same branch + - Review the changes again and notice the update + +--- + +### Feedback and Collaboration + +- Merge requests are a time for feedback and collaboration +- Giving feedback is hard +- Be as kind as possible +- Receiving feedback is hard +- Be as receptive as possible +- Feedback is about the best code, not the person. You are not your code +- Feedback and Collaboration + +--- + +### Feedback and Collaboration + +- Review the Thoughtbot code-review guide for suggestions to follow when reviewing merge requests:[Thoughtbot](https://github.com/thoughtbot/guides/tree/master/code-review) +- See GitLab merge requests for examples: [Merge Requests](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests) + +--- + +## Merge Conflicts + +--- + +### Merge Conflicts +* Happen often +* Learning to fix conflicts is hard +* Practice makes perfect +* Force push after fixing conflicts. Be careful! + +--- + +### Example Plan +1. Checkout a new branch and edit conflicts.rb. Add 'Line4' and 'Line5'. +2. Commit and push +3. Checkout master and edit conflicts.rb. Add 'Line6' and 'Line7' below 'Line3'. +4. Commit and push to master +5. Create a merge request and watch it fail +6. Rebase our new branch with master +7. Fix conflicts on the conflicts.rb file. +8. Stage the file and continue rebasing +9. Force push the changes +10. Finally continue with the Merge Request + +--- + +### Example 1/2 + + git checkout -b conflicts_branch + + # vi conflicts.rb + # Add 'Line4' and 'Line5' + + git commit -am "add line4 and line5" + git push origin conflicts_branch + + git checkout master + + # vi conflicts.rb + # Add 'Line6' and 'Line7' + git commit -am "add line6 and line7" + git push origin master + +--- + +### Example 2/2 + +Create a merge request on the GitLab web UI. You'll see a conflict warning. + + git checkout conflicts_branch + git fetch + git rebase master + + # Fix conflicts by editing the files. + + git add conflicts.rb + # No need to commit this file + + git rebase --continue + + # Remember that we have rewritten our commit history so we + # need to force push so that our remote branch is restructured + git push origin conflicts_branch -f + +--- + +### Notes + +* When to use `git merge` and when to use `git rebase` +* Rebase when updating your branch with master +* Merge when bringing changes from feature to master +* Reference: https://www.atlassian.com/git/tutorials/merging-vs-rebasing/ + +--- + +## Revert and Unstage + +--- + +### Unstage + +To remove files from stage use reset HEAD. Where HEAD is the last commit of the current branch: + + git reset HEAD <file> + +This will unstage the file but maintain the modifications. To revert the file back to the state it was in before the changes we can use: + + git checkout -- <file> + +To remove a file from disk and repo use 'git rm' and to rm a dir use the '-r' flag: + + git rm '*.txt' + git rm -r <dirname> + +If we want to remove a file from the repository but keep it on disk, say we forgot to add it to our .gitignore file then use `--cache`: + + git rm <filename> --cache + +--- + +### Undo Commits + +Undo last commit putting everything back into the staging area: + + git reset --soft HEAD^ + +Add files and change message with: + + git commit --amend -m "New Message" + +Undo last and remove changes + + git reset --hard HEAD^ + +Same as last one but for two commits back: + + git reset --hard HEAD^^ + +Don't reset after pushing + +--- + +### Reset Workflow + +1. Edit file again 'edit_this_file.rb' +2. Check status +3. Add and commit with wrong message +4. Check log +5. Amend commit +6. Check log +7. Soft reset +8. Check log +9. Pull for updates +10. Push changes + +---- + + # Change file edit_this_file.rb + git status + git commit -am "kjkfjkg" + git log + git commit --amend -m "New comment added" + git log + git reset --soft HEAD^ + git log + git pull origin master + git push origin master + +--- + +### Note + +git revert vs git reset +Reset removes the commit while revert removes the changes but leaves the commit +Revert is safer considering we can revert a revert + + + # Changed file + git commit -am "bug introduced" + git revert HEAD + # New commit created reverting changes + # Now we want to re apply the reverted commit + git log # take hash from the revert commit + git revert <rev commit hash> + # reverted commit is back (new commit created again) + +--- + +## Questions + +--- + +## Instructor Notes + +--- + +### Version Control + - Local VCS was used with a filesystem or a simple db. + - Centralized VCS such as Subversion includes collaboration but + still is prone to data loss as the main server is the single point of + failure. + - Distributed VCS enables the team to have a complete copy of the project + and work with little dependency to the main server. In case of a main + server failing the project can be recovered by any of the latest copies + from the team diff --git a/doc/update/4.0-to-4.1.md b/doc/update/4.0-to-4.1.md index c163bfd348d..c66c6dd0fd8 100644 --- a/doc/update/4.0-to-4.1.md +++ b/doc/update/4.0-to-4.1.md @@ -42,7 +42,7 @@ sudo -u gitlab -H bundle exec rake db:migrate RAILS_ENV=production sudo mv /etc/init.d/gitlab /etc/init.d/gitlab.old # get new one using sidekiq -sudo curl -L --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlab-recipes/4-1-stable/init.d/gitlab +sudo curl --location --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlab-recipes/4-1-stable/init.d/gitlab sudo chmod +x /etc/init.d/gitlab ``` diff --git a/doc/update/4.2-to-5.0.md b/doc/update/4.2-to-5.0.md index ee6de51c923..7654f4a0131 100644 --- a/doc/update/4.2-to-5.0.md +++ b/doc/update/4.2-to-5.0.md @@ -126,7 +126,7 @@ sudo chmod -R u+rwX /home/git/gitlab/tmp/pids ```bash # init.d sudo rm /etc/init.d/gitlab -sudo curl -L --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlab-recipes/5-0-stable/init.d/gitlab +sudo curl --location --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlab-recipes/5-0-stable/init.d/gitlab sudo chmod +x /etc/init.d/gitlab # unicorn diff --git a/doc/update/5.0-to-5.1.md b/doc/update/5.0-to-5.1.md index f0fddcf83af..c19a819ab5a 100644 --- a/doc/update/5.0-to-5.1.md +++ b/doc/update/5.0-to-5.1.md @@ -63,7 +63,7 @@ sudo -u git -H bundle exec rake assets:precompile RAILS_ENV=production ```bash # init.d sudo rm /etc/init.d/gitlab -sudo curl -L --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlab-recipes/5-1-stable/init.d/gitlab +sudo curl --location --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlab-recipes/5-1-stable/init.d/gitlab sudo chmod +x /etc/init.d/gitlab ``` diff --git a/doc/update/5.2-to-5.3.md b/doc/update/5.2-to-5.3.md index c5254f6fb0c..fe8990b6843 100644 --- a/doc/update/5.2-to-5.3.md +++ b/doc/update/5.2-to-5.3.md @@ -67,7 +67,7 @@ sudo -u git -H bundle exec rake assets:precompile RAILS_ENV=production ```bash sudo rm /etc/init.d/gitlab -sudo curl -L --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlabhq/5-3-stable/lib/support/init.d/gitlab +sudo curl --location --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlabhq/5-3-stable/lib/support/init.d/gitlab sudo chmod +x /etc/init.d/gitlab ``` diff --git a/doc/update/5.3-to-5.4.md b/doc/update/5.3-to-5.4.md index c4a6146dcda..5f82ad7d444 100644 --- a/doc/update/5.3-to-5.4.md +++ b/doc/update/5.3-to-5.4.md @@ -71,7 +71,7 @@ sudo -u git -H bundle exec rake assets:precompile RAILS_ENV=production ```bash sudo rm /etc/init.d/gitlab -sudo curl -L --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlabhq/5-4-stable/lib/support/init.d/gitlab +sudo curl --location --output /etc/init.d/gitlab https://raw.github.com/gitlabhq/gitlabhq/5-4-stable/lib/support/init.d/gitlab sudo chmod +x /etc/init.d/gitlab ``` diff --git a/doc/update/6.9-to-7.0.md b/doc/update/6.9-to-7.0.md index 236430b5951..5352fd52f93 100644 --- a/doc/update/6.9-to-7.0.md +++ b/doc/update/6.9-to-7.0.md @@ -33,7 +33,7 @@ Download and compile Ruby: ```bash mkdir /tmp/ruby && cd /tmp/ruby -curl -L --progress ftp://ftp.ruby-lang.org/pub/ruby/2.1/ruby-2.1.2.tar.gz | tar xz +curl --location --progress ftp://ftp.ruby-lang.org/pub/ruby/2.1/ruby-2.1.2.tar.gz | tar xz cd ruby-2.1.2 ./configure --disable-install-rdoc make diff --git a/doc/update/7.0-to-7.1.md b/doc/update/7.0-to-7.1.md index a4e9be9946e..71f39c44077 100644 --- a/doc/update/7.0-to-7.1.md +++ b/doc/update/7.0-to-7.1.md @@ -33,7 +33,7 @@ Download and compile Ruby: ```bash mkdir /tmp/ruby && cd /tmp/ruby -curl -L --progress ftp://ftp.ruby-lang.org/pub/ruby/2.1/ruby-2.1.2.tar.gz | tar xz +curl --location --progress ftp://ftp.ruby-lang.org/pub/ruby/2.1/ruby-2.1.2.tar.gz | tar xz cd ruby-2.1.2 ./configure --disable-install-rdoc make diff --git a/doc/update/7.14-to-8.0.md b/doc/update/7.14-to-8.0.md index 305017b7048..117e2afaaa0 100644 --- a/doc/update/7.14-to-8.0.md +++ b/doc/update/7.14-to-8.0.md @@ -71,7 +71,7 @@ sudo -u git -H git checkout v2.6.5 First we download Go 1.5 and install it into `/usr/local/go`: ```bash -curl -O --progress https://storage.googleapis.com/golang/go1.5.linux-amd64.tar.gz +curl --remote-name --progress https://storage.googleapis.com/golang/go1.5.linux-amd64.tar.gz echo '5817fa4b2252afdb02e11e8b9dc1d9173ef3bd5a go1.5.linux-amd64.tar.gz' | shasum -c - && \ sudo tar -C /usr/local -xzf go1.5.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ diff --git a/doc/update/8.0-to-8.1.md b/doc/update/8.0-to-8.1.md index d57c0d0674d..bfb83cf79b1 100644 --- a/doc/update/8.0-to-8.1.md +++ b/doc/update/8.0-to-8.1.md @@ -99,6 +99,10 @@ sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS # Update init.d script sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab ``` + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 7. Update configuration files diff --git a/doc/update/8.1-to-8.2.md b/doc/update/8.1-to-8.2.md index 46dfa2232b4..7f36ce00e96 100644 --- a/doc/update/8.1-to-8.2.md +++ b/doc/update/8.1-to-8.2.md @@ -116,6 +116,10 @@ sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS # Update init.d script sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab ``` + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 7. Update configuration files diff --git a/doc/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md index 25343d484ba..119c5f475e4 100644 --- a/doc/update/8.10-to-8.11.md +++ b/doc/update/8.10-to-8.11.md @@ -20,7 +20,33 @@ cd /home/git/gitlab sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production ``` -### 3. Get latest code +### 3. Update Ruby + +We will continue supporting Ruby < 2.3 for the time being but we recommend you +upgrade to Ruby 2.3 if you're running a source installation, as this is the same +version that ships with our Omnibus package. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz +echo 'c39b4001f7acb4e334cb60a0f4df72d434bef711 ruby-2.3.1.tar.gz' | shasum --check - && tar xzf ruby-2.3.1.tar.gz +cd ruby-2.3.1 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Get latest code ```bash sudo -u git -H git fetch --all @@ -41,15 +67,15 @@ For GitLab Enterprise Edition: sudo -u git -H git checkout 8-11-stable-ee ``` -### 4. Update gitlab-shell +### 5. Update gitlab-shell ```bash cd /home/git/gitlab-shell sudo -u git -H git fetch --all --tags -sudo -u git -H git checkout v3.2.1 +sudo -u git -H git checkout v3.4.0 ``` -### 5. Update gitlab-workhorse +### 6. Update gitlab-workhorse Install and compile gitlab-workhorse. This requires [Go 1.5](https://golang.org/dl) which should already be on your system from @@ -58,11 +84,11 @@ GitLab 8.1. ```bash cd /home/git/gitlab-workhorse sudo -u git -H git fetch --all -sudo -u git -H git checkout v0.7.8 +sudo -u git -H git checkout v0.7.11 sudo -u git -H make ``` -### 6. Install libs, migrations, etc. +### 7. Install libs, migrations, etc. ```bash cd /home/git/gitlab @@ -84,7 +110,7 @@ sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS ``` -### 7. Update configuration files +### 8. Update configuration files #### New configuration options for `gitlab.yml` @@ -132,13 +158,17 @@ See [smtp_settings.rb.sample] as an example. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload -### 8. Start application +### 9. Start application sudo service gitlab start sudo service nginx restart -### 9. Check application status +### 10. Check application status Check if GitLab and its environment are configured correctly: diff --git a/doc/update/8.11-to-8.12.md b/doc/update/8.11-to-8.12.md new file mode 100644 index 00000000000..07743d050f7 --- /dev/null +++ b/doc/update/8.11-to-8.12.md @@ -0,0 +1,205 @@ +# From 8.11 to 8.12 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + + sudo service gitlab stop + +### 2. Backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +We will continue supporting Ruby < 2.3 for the time being but we recommend you +upgrade to Ruby 2.3 if you're running a source installation, as this is the same +version that ships with our Omnibus package. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz +echo 'c39b4001f7acb4e334cb60a0f4df72d434bef711 ruby-2.3.1.tar.gz' | shasum --check - && tar xzf ruby-2.3.1.tar.gz +cd ruby-2.3.1 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Get latest code + +```bash +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +sudo -u git -H git checkout 8-12-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +sudo -u git -H git checkout 8-12-stable-ee +``` + +### 5. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v3.6.0 +``` + +### 6. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab-workhorse +sudo -u git -H git fetch --all +sudo -u git -H git checkout v0.8.2 +sudo -u git -H make +``` + +### 7. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Clean up assets and cache +sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production +``` + +### 8. Update configuration files + +#### New configuration options for `gitlab.yml` + +There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +git diff origin/8-11-stable:config/gitlab.yml.example origin/8-12-stable:config/gitlab.yml.example +``` + +#### Git configuration + +Configure Git to generate packfile bitmaps (introduced in Git 2.0) on +the GitLab server during `git gc`. + +```sh +sudo -u git -H git config --global repack.writeBitmaps true +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +# For HTTPS configurations +git diff origin/8-11-stable:lib/support/nginx/gitlab-ssl origin/8-12-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/8-11-stable:lib/support/nginx/gitlab origin/8-12-stable:lib/support/nginx/gitlab +``` + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-12-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-12-stable/config/initializers/smtp_settings.rb.sample#L13? + +#### Init script + +Ensure you're still up-to-date with the latest init script changes: + + sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload + +### 9. Start application + + sudo service gitlab start + sudo service nginx restart + +### 10. Check application status + +Check if GitLab and its environment are configured correctly: + + sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production + +To make sure you didn't miss anything run a more thorough check: + + sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (8.11) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 8.10 to 8.11](8.10-to-8.11.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. diff --git a/doc/update/8.12-to-8.13.md b/doc/update/8.12-to-8.13.md new file mode 100644 index 00000000000..00d63c1b3c6 --- /dev/null +++ b/doc/update/8.12-to-8.13.md @@ -0,0 +1,205 @@ +# From 8.12 to 8.13 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + + sudo service gitlab stop + +### 2. Backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +We will continue supporting Ruby < 2.3 for the time being but we recommend you +upgrade to Ruby 2.3 if you're running a source installation, as this is the same +version that ships with our Omnibus package. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz +echo 'c39b4001f7acb4e334cb60a0f4df72d434bef711 ruby-2.3.1.tar.gz' | shasum --check - && tar xzf ruby-2.3.1.tar.gz +cd ruby-2.3.1 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Get latest code + +```bash +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +sudo -u git -H git checkout 8-13-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +sudo -u git -H git checkout 8-13-stable-ee +``` + +### 5. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v3.6.3 +``` + +### 6. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab-workhorse +sudo -u git -H git fetch --all +sudo -u git -H git checkout v0.8.4 +sudo -u git -H make +``` + +### 7. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Clean up assets and cache +sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production +``` + +### 8. Update configuration files + +#### New configuration options for `gitlab.yml` + +There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +git diff origin/8-12-stable:config/gitlab.yml.example origin/8-13-stable:config/gitlab.yml.example +``` + +#### Git configuration + +Configure Git to generate packfile bitmaps (introduced in Git 2.0) on +the GitLab server during `git gc`. + +```sh +sudo -u git -H git config --global repack.writeBitmaps true +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +# For HTTPS configurations +git diff origin/8-12-stable:lib/support/nginx/gitlab-ssl origin/8-13-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/8-12-stable:lib/support/nginx/gitlab origin/8-13-stable:lib/support/nginx/gitlab +``` + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-13-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-13-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +Ensure you're still up-to-date with the latest init script changes: + + sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload + +### 9. Start application + + sudo service gitlab start + sudo service nginx restart + +### 10. Check application status + +Check if GitLab and its environment are configured correctly: + + sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production + +To make sure you didn't miss anything run a more thorough check: + + sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (8.12) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 8.11 to 8.12](8.11-to-8.12.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. diff --git a/doc/update/8.2-to-8.3.md b/doc/update/8.2-to-8.3.md index 9f5c6c4dc84..dd3fdafd8d1 100644 --- a/doc/update/8.2-to-8.3.md +++ b/doc/update/8.2-to-8.3.md @@ -158,6 +158,10 @@ it where the 'public' directory of GitLab is. cd /home/git/gitlab sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab ``` + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 8. Use Redis v2.8.0+ diff --git a/doc/update/8.3-to-8.4.md b/doc/update/8.3-to-8.4.md index 9f6517d9487..e62d894609a 100644 --- a/doc/update/8.3-to-8.4.md +++ b/doc/update/8.3-to-8.4.md @@ -98,6 +98,10 @@ We updated the init script for GitLab in order to set a specific PATH for gitlab cd /home/git/gitlab sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab ``` + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 8. Start application diff --git a/doc/update/8.4-to-8.5.md b/doc/update/8.4-to-8.5.md index 0cb137a03cc..678cc69d773 100644 --- a/doc/update/8.4-to-8.5.md +++ b/doc/update/8.4-to-8.5.md @@ -119,6 +119,10 @@ via [/etc/default/gitlab]. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 8. Start application diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md index 6267f14eba4..a76346516b9 100644 --- a/doc/update/8.5-to-8.6.md +++ b/doc/update/8.5-to-8.6.md @@ -138,6 +138,10 @@ via [/etc/default/gitlab]. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 9. Start application diff --git a/doc/update/8.6-to-8.7.md b/doc/update/8.6-to-8.7.md index cb66ef920bb..05ef4e61759 100644 --- a/doc/update/8.6-to-8.7.md +++ b/doc/update/8.6-to-8.7.md @@ -127,6 +127,10 @@ via [/etc/default/gitlab]. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 8. Start application diff --git a/doc/update/8.7-to-8.8.md b/doc/update/8.7-to-8.8.md index 32906650f6f..8ce434e5f78 100644 --- a/doc/update/8.7-to-8.8.md +++ b/doc/update/8.7-to-8.8.md @@ -127,6 +127,10 @@ via [/etc/default/gitlab]. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 8. Start application diff --git a/doc/update/8.8-to-8.9.md b/doc/update/8.8-to-8.9.md index f078a2bece5..aa077316bbe 100644 --- a/doc/update/8.8-to-8.9.md +++ b/doc/update/8.8-to-8.9.md @@ -156,6 +156,10 @@ See [smtp_settings.rb.sample] as an example. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 9. Start application diff --git a/doc/update/8.9-to-8.10.md b/doc/update/8.9-to-8.10.md index a057a423e61..bb2c79fbb84 100644 --- a/doc/update/8.9-to-8.10.md +++ b/doc/update/8.9-to-8.10.md @@ -156,6 +156,10 @@ See [smtp_settings.rb.sample] as an example. Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +For Ubuntu 16.04.1 LTS: + + sudo systemctl daemon-reload ### 9. Start application diff --git a/doc/user/account/security.md b/doc/user/account/security.md new file mode 100644 index 00000000000..816094bf8d2 --- /dev/null +++ b/doc/user/account/security.md @@ -0,0 +1,3 @@ +# Account Security + +- [Two-Factor Authentication](two_factor_authentication.md) diff --git a/doc/user/account/two_factor_authentication.md b/doc/user/account/two_factor_authentication.md new file mode 100644 index 00000000000..881358ed94d --- /dev/null +++ b/doc/user/account/two_factor_authentication.md @@ -0,0 +1,68 @@ +# Two-Factor Authentication + +## Recovery options + +If you lose your code generation device (such as your mobile phone) and you need +to disable two-factor authentication on your account, you have several options. + +### Use a saved recovery code + +When you enabled two-factor authentication for your account, a series of +recovery codes were generated. If you saved those codes somewhere safe, you +may use one to sign in. + +First, enter your username/email and password on the GitLab sign in page. When +prompted for a two-factor code, enter one of the recovery codes you saved +previously. + +> **Note:** Once a particular recovery code has been used, it cannot be used again. + You may still use the other saved recovery codes at a later time. + +### Generate new recovery codes using SSH + +It's not uncommon for users to forget to save the recovery codes when enabling +two-factor authentication. If you have an SSH key added to your GitLab account, +you can generate a new set of recovery codes using SSH. + +Run `ssh git@gitlab.example.com 2fa_recovery_codes`. You will be prompted to +confirm that you wish to generate new codes. If you choose to continue, any +previously saved codes will be invalidated. + +```bash +$ ssh git@gitlab.example.com 2fa_recovery_codes +Are you sure you want to generate new two-factor recovery codes? +Any existing recovery codes you saved will be invalidated. (yes/no) +yes + +Your two-factor authentication recovery codes are: + +119135e5a3ebce8e +11f6v2a498810dcd +3924c7ab2089c902 +e79a3398bfe4f224 +34bd7b74adbc8861 +f061691d5107df1a +169bf32a18e63e7f +b510e7422e81c947 +20dbed24c5e74663 +df9d3b9403b9c9f0 + +During sign in, use one of the codes above when prompted for +your two-factor code. Then, visit your Profile Settings and add +a new device so you do not lose access to your account again. +``` + +Next, go to the GitLab sign in page and enter your username/email and password. +When prompted for a two-factor code, enter one of the recovery codes obtained +from the command line output. + +> **Note:** After signing in, you should immediately visit your **Profile Settings + -> Account** to set up two-factor authentication with a new device. + +### Ask a GitLab administrator to disable two-factor on your account + +If the above two methods are not possible, you may ask a GitLab global +administrator to disable two-factor authentication for your account. Please +be aware that this will temporarily leave your account in a less secure state. +You should sign in and re-enable two-factor authentication as soon as possible +after the administrator disables it. diff --git a/doc/user/admin_area/img/admin_labels.png b/doc/user/admin_area/img/admin_labels.png Binary files differnew file mode 100644 index 00000000000..1ee33a534ab --- /dev/null +++ b/doc/user/admin_area/img/admin_labels.png diff --git a/doc/user/admin_area/labels.md b/doc/user/admin_area/labels.md new file mode 100644 index 00000000000..9e2a89ebdf6 --- /dev/null +++ b/doc/user/admin_area/labels.md @@ -0,0 +1,9 @@ +# Labels + +## Default Labels + +### Define your own default Label Set + +Labels that are created within the Labels view on the Admin Dashboard will be automatically added to each new project. + + diff --git a/doc/user/admin_area/monitoring/health_check.md b/doc/user/admin_area/monitoring/health_check.md new file mode 100644 index 00000000000..eac57bc3de4 --- /dev/null +++ b/doc/user/admin_area/monitoring/health_check.md @@ -0,0 +1,66 @@ +# Health Check + +> [Introduced][ce-3888] in GitLab 8.8. + +GitLab provides a health check endpoint for uptime monitoring on the `health_check` web +endpoint. The health check reports on the overall system status based on the status of +the database connection, the state of the database migrations, and the ability to write +and access the cache. This endpoint can be provided to uptime monitoring services like +[Pingdom][pingdom], [Nagios][nagios-health], and [NewRelic][newrelic-health]. + +## Access Token + +An access token needs to be provided while accessing the health check endpoint. The current +accepted token can be found on the `admin/health_check` page of your GitLab instance. + + + +The access token can be passed as a URL parameter: + +``` +https://gitlab.example.com/health_check.json?token=ACCESS_TOKEN +``` + +or as an HTTP header: + +```bash +curl --header "TOKEN: ACCESS_TOKEN" https://gitlab.example.com/health_check.json +``` + +## Using the Endpoint + +Once you have the access token, health information can be retrieved as plain text, JSON, +or XML using the `health_check` endpoint: + +- `https://gitlab.example.com/health_check?token=ACCESS_TOKEN` +- `https://gitlab.example.com/health_check.json?token=ACCESS_TOKEN` +- `https://gitlab.example.com/health_check.xml?token=ACCESS_TOKEN` + +You can also ask for the status of specific services: + +- `https://gitlab.example.com/health_check/cache.json?token=ACCESS_TOKEN` +- `https://gitlab.example.com/health_check/database.json?token=ACCESS_TOKEN` +- `https://gitlab.example.com/health_check/migrations.json?token=ACCESS_TOKEN` + +For example, the JSON output of the following health check: + +```bash +curl --header "TOKEN: ACCESS_TOKEN" https://gitlab.example.com/health_check.json +``` + +would be like: + +``` +{"healthy":true,"message":"success"} +``` + +## Status + +On failure, the endpoint will return a `500` HTTP status code. On success, the endpoint +will return a valid successful HTTP status code, and a `success` message. Ideally your +uptime monitoring should look for the success message. + +[ce-3888]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3888 +[pingdom]: https://www.pingdom.com +[nagios-health]: https://nagios-plugins.org/doc/man/check_http.html +[newrelic-health]: https://docs.newrelic.com/docs/alerts/alert-policies/downtime-alerts/availability-monitoring diff --git a/doc/monitoring/img/health_check_token.png b/doc/user/admin_area/monitoring/img/health_check_token.png Binary files differindex 2d7c82a65a8..2d7c82a65a8 100644 --- a/doc/monitoring/img/health_check_token.png +++ b/doc/user/admin_area/monitoring/img/health_check_token.png diff --git a/doc/user/admin_area/settings/continuous_integration.md b/doc/user/admin_area/settings/continuous_integration.md new file mode 100644 index 00000000000..34e2e557f89 --- /dev/null +++ b/doc/user/admin_area/settings/continuous_integration.md @@ -0,0 +1,20 @@ +# Continuous integration Admin settings + +## Maximum artifacts size + +The maximum size of the [build artifacts][art-yml] can be set in the Admin area +of your GitLab instance. The value is in MB and the default is 100MB. Note that +this setting is set for each build. + +1. Go to **Admin area > Settings** (`/admin/application_settings`). + +  + +1. Change the value of the maximum artifacts size (in MB): + +  + +1. Hit **Save** for the changes to take effect. + + +[art-yml]: ../../../administration/build_artifacts.md diff --git a/doc/user/admin_area/settings/img/admin_area_maximum_artifacts_size.png b/doc/user/admin_area/settings/img/admin_area_maximum_artifacts_size.png Binary files differnew file mode 100644 index 00000000000..53f7e76033e --- /dev/null +++ b/doc/user/admin_area/settings/img/admin_area_maximum_artifacts_size.png diff --git a/doc/user/admin_area/settings/img/admin_area_settings_button.png b/doc/user/admin_area/settings/img/admin_area_settings_button.png Binary files differnew file mode 100644 index 00000000000..509708b627f --- /dev/null +++ b/doc/user/admin_area/settings/img/admin_area_settings_button.png diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 7fe96e67dbb..56e5b802a52 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -27,6 +27,7 @@ * [Horizontal Rule](#horizontal-rule) * [Line Breaks](#line-breaks) * [Tables](#tables) +* [Footnotes](#footnotes) **[Wiki-Specific Markdown](#wiki-specific-markdown)** @@ -66,7 +67,7 @@ dependency to do so. Please see the [github-markup gem readme](https://github.co ## Newlines > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#newlines +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#newlines GFM honors the markdown specification in how [paragraphs and line breaks are handled](https://daringfireball.net/projects/markdown/syntax#p). @@ -86,7 +87,7 @@ Sugar is sweet ## Multiple underscores in words > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#multiple-underscores-in-words +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#multiple-underscores-in-words It is not reasonable to italicize just _part_ of a word, especially when you're dealing with code and names that often appear with multiple underscores. Therefore, GFM ignores multiple underscores in words: @@ -101,7 +102,7 @@ do_this_and_do_that_and_another_thing ## URL auto-linking > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#url-auto-linking +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#url-auto-linking GFM will autolink almost any URL you copy and paste into your text: @@ -122,7 +123,7 @@ GFM will autolink almost any URL you copy and paste into your text: ## Multiline Blockquote > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#multiline-blockquote +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#multiline-blockquote On top of standard Markdown [blockquotes](#blockquotes), which require prepending `>` to quoted lines, GFM supports multiline blockquotes fenced by <code>>>></code>: @@ -156,7 +157,7 @@ you can quote that without having to manually prepend `>` to every line! ## Code and Syntax Highlighting > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#code-and-syntax-highlighting +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting _GitLab uses the [Rouge Ruby library][rouge] for syntax highlighting. For a list of supported languages visit the Rouge website._ @@ -226,7 +227,7 @@ But let's throw in a <b>tag</b>. ## Inline Diff > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#inline-diff +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#inline-diff With inline diffs tags you can display {+ additions +} or [- deletions -]. @@ -242,7 +243,7 @@ However the wrapping tags cannot be mixed as such: ## Emoji > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#emoji +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#emoji Sometimes you want to :monkey: around a bit and add some :star2: to your :speech_balloon:. Well we have a gift for you: @@ -307,7 +308,7 @@ GFM also recognizes certain cross-project references: ## Task Lists > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#task-lists +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#task-lists You can add task lists to issues, merge requests and comments. To create a task list, add a specially-formatted Markdown list, like so: @@ -330,7 +331,7 @@ Task lists can only be created in descriptions, not in titles. Task item state c ## Videos > If this is not rendered correctly, see -https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#videos +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#videos Image tags with a video extension are automatically converted to a video player. @@ -699,6 +700,15 @@ By including colons in the header row, you can align the text within that column | Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | Cell 6 | | Cell 7 | Cell 8 | Cell 9 | Cell 10 | Cell 11 | Cell 12 | +## Footnotes + +You can add footnotes to your text as follows.[^1] +[^1]: This is my awesome footnote. + +``` +You can add footnotes to your text as follows.[^1] +[^1]: This is my awesome footnote. +``` ## Wiki-specific Markdown @@ -780,7 +790,7 @@ A link starting with a `/` is relative to the wiki root. - The [Markdown Syntax Guide](https://daringfireball.net/projects/markdown/syntax) at Daring Fireball is an excellent resource for a detailed explanation of standard markdown. - [Dillinger.io](http://dillinger.io) is a handy tool for testing standard markdown. -[markdown.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md +[markdown.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md [rouge]: http://rouge.jneen.net/ "Rouge website" [redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website" [^1]: This link will be broken if you see this document from the Help page or docs.gitlab.com diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 66542861781..d6216a8dd50 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -23,6 +23,7 @@ The following table depicts the various user permission levels in a project. | See a list of builds | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | | See a build log | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | | Download and browse build artifacts | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | +| View wiki pages | ✓ | ✓ | ✓ | ✓ | ✓ | | Pull project code | | ✓ | ✓ | ✓ | ✓ | | Download project | | ✓ | ✓ | ✓ | ✓ | | Create code snippets | | ✓ | ✓ | ✓ | ✓ | @@ -31,6 +32,7 @@ The following table depicts the various user permission levels in a project. | See a commit status | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | | See environments | | ✓ | ✓ | ✓ | ✓ | +| See a list of merge requests | | ✓ | ✓ | ✓ | ✓ | | Manage/Accept merge requests | | | ✓ | ✓ | ✓ | | Create new merge request | | | ✓ | ✓ | ✓ | | Create new branches | | | ✓ | ✓ | ✓ | @@ -63,7 +65,7 @@ The following table depicts the various user permission levels in a project. | Force push to protected branches [^2] | | | | | | | Remove protected branches [^2] | | | | | | -[^1]: If **Allow guest to access builds** is enabled in CI settings +[^1]: If **Public pipelines** is enabled in **Project Settings > CI/CD Pipelines** [^2]: Not allowed for Guest, Reporter, Developer, Master, or Owner ## Group @@ -104,6 +106,15 @@ will find the option to flag the user as external. By default new users are not set as external users. This behavior can be changed by an administrator under **Admin > Application Settings**. +## Project features + +Project features like wiki and issues can be hidden from users depending on +which visibility level you select on project settings. + +- Disabled: disabled for everyone +- Only team members: only team members will see even if your project is public or internal +- Everyone with access: everyone can see depending on your project visibility level + ## GitLab CI GitLab CI permissions rely on the role the user has in GitLab. There are four @@ -129,3 +140,33 @@ instance and project. In addition, all admins can use the admin interface under | Add shared runners | | | | ✓ | | See events in the system | | | | ✓ | | Admin interface | | | | ✓ | + +### Build permissions + +> Changed in GitLab 8.12. + +GitLab 8.12 has a completely redesigned build permissions system. +Read all about the [new model and its implications][new-mod]. + +This table shows granted privileges for builds triggered by specific types of +users: + +| Action | Guest, Reporter | Developer | Master | Admin | +|---------------------------------------------|-----------------|-------------|----------|--------| +| Run CI build | | ✓ | ✓ | ✓ | +| Clone source and LFS from current project | | ✓ | ✓ | ✓ | +| Clone source and LFS from public projects | | ✓ | ✓ | ✓ | +| Clone source and LFS from internal projects | | ✓ [^3] | ✓ [^3] | ✓ | +| Clone source and LFS from private projects | | ✓ [^4] | ✓ [^4] | ✓ [^4] | +| Push source and LFS | | | | | +| Pull container images from current project | | ✓ | ✓ | ✓ | +| Pull container images from public projects | | ✓ | ✓ | ✓ | +| Pull container images from internal projects| | ✓ [^3] | ✓ [^3] | ✓ | +| Pull container images from private projects | | ✓ [^4] | ✓ [^4] | ✓ [^4] | +| Push container images to current project | | ✓ | ✓ | ✓ | +| Push container images to other projects | | | | | + +[^3]: Only if user is not external one. +[^4]: Only if user is a member of the project. +[ce-18994]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18994 +[new-mod]: project/new_ci_build_permissions_model.md diff --git a/doc/user/project/builds/artifacts.md b/doc/user/project/builds/artifacts.md new file mode 100644 index 00000000000..88f1863dddb --- /dev/null +++ b/doc/user/project/builds/artifacts.md @@ -0,0 +1,136 @@ +# Introduction to build artifacts + +>**Notes:** +>- Since GitLab 8.2 and GitLab Runner 0.7.0, build artifacts that are created by + GitLab Runner are uploaded to GitLab and are downloadable as a single archive + (`tar.gz`) using the GitLab UI. +>- Starting from GitLab 8.4 and GitLab Runner 1.0, the artifacts archive format + changed to `ZIP`, and it is now possible to browse its contents, with the added + ability of downloading the files separately. +>- The artifacts browser will be available only for new artifacts that are sent + to GitLab using GitLab Runner version 1.0 and up. It will not be possible to + browse old artifacts already uploaded to GitLab. +>- This is the user documentation. For the administration guide see + [administration/build_artifacts.md](../../../administration/build_artifacts.md). + +Artifacts is a list of files and directories which are attached to a build +after it completes successfully. This feature is enabled by default in all GitLab installations. + +## Defining artifacts in `.gitlab-ci.yml` + +A simple example of using the artifacts definition in `.gitlab-ci.yml` would be +the following: + +```yaml +pdf: + script: xelatex mycv.tex + artifacts: + paths: + - mycv.pdf +``` + +A job named `pdf` calls the `xelatex` command in order to build a pdf file from +the latex source file `mycv.tex`. We then define the `artifacts` paths which in +turn are defined with the `paths` keyword. All paths to files and directories +are relative to the repository that was cloned during the build. + +For more examples on artifacts, follow the artifacts reference in +[`.gitlab-ci.yml` documentation](../../../ci/yaml/README.md#artifacts). + +## Browsing build artifacts + +When GitLab receives an artifacts archive, an archive metadata file is also +generated. This metadata file describes all the entries that are located in the +artifacts archive itself. The metadata file is in a binary format, with +additional GZIP compression. + +GitLab does not extract the artifacts archive in order to save space, memory +and disk I/O. It instead inspects the metadata file which contains all the +relevant information. This is especially important when there is a lot of +artifacts, or an archive is a very large file. + +--- + +After a build finishes, if you visit the build's specific page, you can see +that there are two buttons. One is for downloading the artifacts archive and +the other for browsing its contents. + + + +--- + +The archive browser shows the name and the actual file size of each file in the +archive. If your artifacts contained directories, then you are also able to +browse inside them. + +Below you can see how browsing looks like. In this case we have browsed inside +the archive and at this point there is one directory and one HTML file. + + + +--- + +## Downloading build artifacts + +>**Note:** +GitLab does not extract the entire artifacts archive to send just a single file +to the user. When clicking on a specific file, [GitLab Workhorse] extracts it +from the archive and the download begins. This implementation saves space, +memory and disk I/O. + +If you need to download the whole archive, there are buttons in various places +inside GitLab that make that possible. + +1. While on the pipelines page, you can see the download icon for each build's + artifacts archive in the right corner: + +  + +1. While on the builds page, you can see the download icon for each build's + artifacts archive in the right corner: + +  + +1. While inside a specific build, you are presented with a download button + along with the one that browses the archive: + +  + +1. And finally, when browsing an archive you can see the download button at + the top right corner: + +  + +## Downloading the latest build artifacts + +It is possible to download the latest artifacts of a build via a well known URL +so you can use it for scripting purposes. + +The structure of the URL is the following: + +``` +https://example.com/<namespace>/<project>/builds/artifacts/<ref>/download?job=<job_name> +``` + +For example, to download the latest artifacts of the job named `rspec 6 20` of +the `master` branch of the `gitlab-ce` project that belongs to the `gitlab-org` +namespace, the URL would be: + +``` +https://gitlab.com/gitlab-org/gitlab-ce/builds/artifacts/master/download?job=rspec+6+20 +``` + +The latest builds are also exposed in the UI in various places. Specifically, +look for the download button in: + +- the main project's page +- the branches page +- the tags page + +If the latest build has failed to upload the artifacts, you can see that +information in the UI. + + + + +[gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository" diff --git a/doc/user/project/builds/img/build_artifacts_browser.png b/doc/user/project/builds/img/build_artifacts_browser.png Binary files differnew file mode 100644 index 00000000000..d95e2800c0f --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_browser.png diff --git a/doc/user/project/builds/img/build_artifacts_browser_button.png b/doc/user/project/builds/img/build_artifacts_browser_button.png Binary files differnew file mode 100644 index 00000000000..463540634e3 --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_browser_button.png diff --git a/doc/user/project/builds/img/build_artifacts_builds_page.png b/doc/user/project/builds/img/build_artifacts_builds_page.png Binary files differnew file mode 100644 index 00000000000..db78386ba7b --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_builds_page.png diff --git a/doc/user/project/builds/img/build_artifacts_pipelines_page.png b/doc/user/project/builds/img/build_artifacts_pipelines_page.png Binary files differnew file mode 100644 index 00000000000..6c2d1a4bdc7 --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_pipelines_page.png diff --git a/doc/user/project/builds/img/build_latest_artifacts_browser.png b/doc/user/project/builds/img/build_latest_artifacts_browser.png Binary files differnew file mode 100644 index 00000000000..d8e9071958c --- /dev/null +++ b/doc/user/project/builds/img/build_latest_artifacts_browser.png diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md new file mode 100644 index 00000000000..b205fea2c40 --- /dev/null +++ b/doc/user/project/container_registry.md @@ -0,0 +1,253 @@ +# GitLab Container Registry + +> [Introduced][ce-4040] in GitLab 8.8. + +--- + +> **Note** +Docker Registry manifest `v1` support was added in GitLab 8.9 to support Docker +versions earlier than 1.10. +> +This document is about the user guide. To learn how to enable GitLab Container +Registry across your GitLab instance, visit the +[administrator documentation](../../administration/container_registry.md). + +With the Docker Container Registry integrated into GitLab, every project can +have its own space to store its Docker images. + +You can read more about Docker Registry at https://docs.docker.com/registry/introduction/. + +--- + +## Enable the Container Registry for your project + +1. First, ask your system administrator to enable GitLab Container Registry + following the [administration documentation](../../administration/container_registry.md). + If you are using GitLab.com, this is enabled by default so you can start using + the Registry immediately. + +1. Go to your project's settings and enable the **Container Registry** feature + on your project. For new projects this might be enabled by default. For + existing projects (prior GitLab 8.8), you will have to explicitly enable it. + +  + +1. Hit **Save changes** for the changes to take effect. You should now be able + to see the **Registry** link in the project menu. + +  + +## Build and push images + +If you visit the **Registry** link under your project's menu, you can see the +explicit instructions to login to the Container Registry using your GitLab +credentials. + +For example if the Registry's URL is `registry.example.com`, the you should be +able to login with: + +``` +docker login registry.example.com +``` + +Building and publishing images should be a straightforward process. Just make +sure that you are using the Registry URL with the namespace and project name +that is hosted on GitLab: + +``` +docker build -t registry.example.com/group/project . +docker push registry.example.com/group/project +``` + +Your image will be named after the following scheme: + +``` +<registry URL>/<namespace>/<project> +``` + +As such, the name of the image is unique, but you can differentiate the images +using tags. + +## Use images from GitLab Container Registry + +To download and run a container from images hosted in GitLab Container Registry, +use `docker run`: + +``` +docker run [options] registry.example.com/group/project [arguments] +``` + +For more information on running Docker containers, visit the +[Docker documentation][docker-docs]. + +## Control Container Registry from within GitLab + +GitLab offers a simple Container Registry management panel. Go to your project +and click **Registry** in the project menu. + +This view will show you all tags in your project and will easily allow you to +delete them. + + + +## Build and push images using GitLab CI + +> **Note:** +This feature requires GitLab 8.8 and GitLab Runner 1.2. + +Make sure that your GitLab Runner is configured to allow building Docker images by +following the [Using Docker Build](../ci/docker/using_docker_build.md) +and [Using the GitLab Container Registry documentation](../ci/docker/using_docker_build.md#using-the-gitlab-container-registry). + +## Limitations + +In order to use a container image from your private project as an `image:` in +your `.gitlab-ci.yml`, you have to follow the +[Using a private Docker Registry][private-docker] +documentation. This workflow will be simplified in the future. + +## Troubleshooting the GitLab Container Registry + +### Basic Troubleshooting + +1. Check to make sure that the system clock on your Docker client and GitLab server have + been synchronized (e.g. via NTP). + +2. If you are using an S3-backed Registry, double check that the IAM + permissions and the S3 credentials (including region) are correct. See [the + sample IAM policy](https://docs.docker.com/registry/storage-drivers/s3/) + for more details. + +3. Check the Registry logs (e.g. `/var/log/gitlab/registry/current`) and the GitLab production logs + for errors (e.g. `/var/log/gitlab/gitlab-rails/production.log`). You may be able to find clues + there. + +### Advanced Troubleshooting + +>**NOTE:** The following section is only recommended for experts. + +Sometimes it's not obvious what is wrong, and you may need to dive deeper into +the communication between the Docker client and the Registry to find out +what's wrong. We will use a concrete example in the past to illustrate how to +diagnose a problem with the S3 setup. + +#### Unexpected 403 error during push + +A user attempted to enable an S3-backed Registry. The `docker login` step went +fine. However, when pushing an image, the output showed: + +``` +The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test] +dc5e59c14160: Pushing [==================================================>] 14.85 kB +03c20c1a019a: Pushing [==================================================>] 2.048 kB +a08f14ef632e: Pushing [==================================================>] 2.048 kB +228950524c88: Pushing 2.048 kB +6a8ecde4cc03: Pushing [==> ] 9.901 MB/205.7 MB +5f70bf18a086: Pushing 1.024 kB +737f40e80b7f: Waiting +82b57dbc5385: Waiting +19429b698a22: Waiting +9436069b92a3: Waiting +error parsing HTTP 403 response body: unexpected end of JSON input: "" +``` + +This error is ambiguous, as it's not clear whether the 403 is coming from the +GitLab Rails application, the Docker Registry, or something else. In this +case, since we know that since the login succeeded, we probably need to look +at the communication between the client and the Registry. + +The REST API between the Docker client and Registry is [described +here](https://docs.docker.com/registry/spec/api/). Normally, one would just +use Wireshark or tcpdump to capture the traffic and see where things went +wrong. However, since all communication between Docker clients and servers +are done over HTTPS, it's a bit difficult to decrypt the traffic quickly even +if you know the private key. What can we do instead? + +One way would be to disable HTTPS by setting up an [insecure +Registry](https://docs.docker.com/registry/insecure/). This could introduce a +security hole and is only recommended for local testing. If you have a +production system and can't or don't want to do this, there is another way: +use mitmproxy, which stands for Man-in-the-Middle Proxy. + +#### mitmproxy + +[mitmproxy](https://mitmproxy.org/) allows you to place a proxy between your +client and server to inspect all traffic. One wrinkle is that your system +needs to trust the mitmproxy SSL certificates for this to work. + +The following installation instructions assume you are running Ubuntu: + +1. Install mitmproxy (see http://docs.mitmproxy.org/en/stable/install.html) +1. Run `mitmproxy --port 9000` to generate its certificates. + Enter <kbd>CTRL</kbd>-<kbd>C</kbd> to quit. +1. Install the certificate from `~/.mitmproxy` to your system: + + ```sh + sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt + sudo update-ca-certificates + ``` + +If successful, the output should indicate that a certificate was added: + +```sh +Updating certificates in /etc/ssl/certs... 1 added, 0 removed; done. +Running hooks in /etc/ca-certificates/update.d....done. +``` + +To verify that the certificates are properly installed, run: + +```sh +mitmproxy --port 9000 +``` + +This will run mitmproxy on port `9000`. In another window, run: + +```sh +curl --proxy http://localhost:9000 https://httpbin.org/status/200 +``` + +If everything is setup correctly, you will see information on the mitmproxy window and +no errors from the curl commands. + +#### Running the Docker daemon with a proxy + +For Docker to connect through a proxy, you must start the Docker daemon with the +proper environment variables. The easiest way is to shutdown Docker (e.g. `sudo initctl stop docker`) +and then run Docker by hand. As root, run: + +```sh +export HTTP_PROXY="http://localhost:9000" +export HTTPS_PROXY="https://localhost:9000" +docker daemon --debug +``` + +This will launch the Docker daemon and proxy all connections through mitmproxy. + +#### Running the Docker client + +Now that we have mitmproxy and Docker running, we can attempt to login and push +a container image. You may need to run as root to do this. For example: + +```sh +docker login s3-testing.myregistry.com:4567 +docker push s3-testing.myregistry.com:4567/root/docker-test +``` + +In the example above, we see the following trace on the mitmproxy window: + + + +The above image shows: + +* The initial PUT requests went through fine with a 201 status code. +* The 201 redirected the client to the S3 bucket. +* The HEAD request to the AWS bucket reported a 403 Unauthorized. + +What does this mean? This strongly suggests that the S3 user does not have the right +[permissions to perform a HEAD request](http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html). +The solution: check the [IAM permissions again](https://docs.docker.com/registry/storage-drivers/s3/). +Once the right permissions were set, the error will go away. + +[ce-4040]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4040 +[docker-docs]: https://docs.docker.com/engine/userguide/intro/ +[private-docker]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/configuration/advanced-configuration.md#using-a-private-docker-registry diff --git a/doc/user/project/cycle_analytics.md b/doc/user/project/cycle_analytics.md new file mode 100644 index 00000000000..1892ccabb70 --- /dev/null +++ b/doc/user/project/cycle_analytics.md @@ -0,0 +1,170 @@ +# Cycle Analytics + +> [Introduced][ce-5986] in GitLab 8.12. +> +> **Note:** +There are more changes coming to Cycle Analytics, you can follow the following +issue to track the changes to this feature: [#20975][ce-20975]. + +Cycle Analytics measures the time it takes to go from an [idea to production] for +each project you have. This is achieved by not only indicating the total time it +takes to reach at that point, but the total time is broken down into the +multiple stages an idea has to pass through to be shipped. + +Cycle Analytics is that it is tightly coupled with the [GitLab flow] and +calculates a separate median for each stage. + +## Overview + +You can find the Cycle Analytics page under your project's **Pipelines > Cycle +Analytics** tab. + + + +You can see that there are seven stages in total: + +- **Issue** (Tracker) + - Median time from issue creation until given a milestone or list label + (first assignment, any milestone, milestone date or assignee is not required) +- **Plan** (Board) + - Median time from giving an issue a milestone or label until pushing the + first commit to the branch +- **Code** (IDE) + - Median time from the first commit to the branch until the merge request is + created +- **Test** (CI) + - Median total test time for all commits/merges +- **Review** (Merge Request/MR) + - Median time from merge request creation until the merge request is merged + (closed merge requests won't be taken into account) +- **Staging** (Continuous Deployment) + - Median time from when the merge request got merged until the deploy to + production (production is last stage/environment) +- **Production** (Total) + - Sum of all the above stages' times excluding the Test (CI) time. To clarify, + it's not so much that CI time is "excluded", but rather CI time is already + counted in the review stage since CI is done automatically. Most of the + other stages are purely sequential, but **Test** is not. + +## How the data is measured + +Cycle Analytics records cycle time and data based on the project issues with the +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. + +Below you can see in more detail what the various stages of Cycle Analytics mean. + +| **Stage** | **Description** | +| --------- | --------------- | +| Issue | Measures the median time between creating an issue and taking action to solve it, by either labeling it or adding it to a milestone, whatever comes first. The label will be tracked only if it already has an [Issue Board list][board] created for it. | +| Plan | Measures the median time between the action you took for the previous stage, and pushing the first commit to the branch. The very first commit of the branch is the one that triggers the separation between **Plan** and **Code**, and at least one of the commits in the branch needs to contain the related issue number (e.g., `#42`). If none of the commits in the branch mention the related issue number, it is not considered to the measurement time of the stage. | +| 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. | +| Production| The sum of all time (medians) taken to run the entire process, from issue creation to deploying the code to production. | + +--- + +Here's a little explanation of how this works behind the scenes: + +1. Issues and merge requests are grouped together in pairs, such that for each + `<issue, merge request>` pair, the merge request has the [issue closing pattern] + for the corresponding issue. All other issues and merge requests are **not** + considered. +1. Then the <issue, merge request> pairs are filtered out by last XX days (specified + by the UI - default is 90 days). So it prohibits these pairs from being considered. +1. For the remaining `<issue, merge request>` pairs, we check the information that + we need for the stages, like issue creation date, merge request merge time, + 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. + +## Example workflow + +Below is a simple fictional workflow of a single cycle that happens in a +single day passing through all seven stages. Note that if a stage does not have +a start/stop mark, it is not measured and hence not calculated in the median +time. It is assumed that milestones are created and CI for testing and setting +environments is configured. + +1. Issue is created at 09:00 (start of **Issue** stage). +1. Issue is added to a milestone at 11:00 (stop of **Issue** stage / start of + **Plan** stage). +1. Start working on the issue, create a branch locally and make one commit at + 12:00. +1. Make a second commit to the branch which mentions the issue number at 12.30 + (stop of **Plan** stage / start of **Code** stage). +1. Push branch and create a merge request that contains the [issue closing pattern] + in its description at 14:00 (stop of **Code** stage / start of **Test** and + **Review** stages). +1. The CI starts running your scripts defined in [`.gitlab-ci.yml`][yml] and + takes 5min (stop of **Test** stage). +1. Review merge request, ensure that everything is OK and merge the merge + request at 19:00. (stop of **Review** stage / start of **Staging** stage). +1. Now that the merge request is merged, a deployment to the `production` + environment starts and finishes at 19:30 (stop of **Staging** stage). +1. The cycle completes and the sum of the median times of the previous stages + is recorded to the **Production** stage. That is the time between creating an + issue and deploying its relevant merge request to production. + +From the above example you can conclude the time it took each stage to complete +as long as their total time: + +- **Issue**: 2h (11:00 - 09:00) +- **Plan**: 1h (12:00 - 11:00) +- **Code**: 2h (14:00 - 12:00) +- **Test**: 5min +- **Review**: 5h (19:00 - 14:00) +- **Staging**: 30min (19:30 - 19:00) +- **Production**: Since this stage measures the sum of median time off all + previous stages, we cannot calculate it if we don't know the status of the + stages before. In case this is the very first cycle that is run in the project, + then the **Production** time is 10h 30min (19:30 - 09:00) + +A few notes: + +- In the above example we demonstrated that it doesn't matter if your first + commit doesn't mention the issue number, you can do this later in any commit + of the branch you are working on. +- You can see that the **Test** stage is not calculated to the overall time of + the cycle since it is included in the **Review** process (every MR should be + tested). +- The example above was just **one cycle** of the seven stages. Add multiple + cycles, calculate their median time and the result is what the dashboard of + Cycle Analytics is showing. + +## Permissions + +The current permissions on the Cycle Analytics dashboard are: + +- Public projects - anyone can access +- Private/internal projects - any member (guest level and above) can access + +You can [read more about permissions][permissions] in general. + +## More resources + +Learn more about Cycle Analytics in the following resources: + +- [Cycle Analytics feature page](https://about.gitlab.com/solutions/cycle-analytics/) +- [Cycle Analytics feature preview](https://about.gitlab.com/2016/09/16/feature-preview-introducing-cycle-analytics/) +- [Cycle Analytics feature highlight](https://about.gitlab.com/2016/09/21/cycle-analytics-feature-highlight/) + + +[board]: issue_board.md#creating-a-new-list +[ce-5986]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5986 +[ce-20975]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20975 +[environment]: ../../ci/yaml/README.md#environment +[GitLab flow]: ../../workflow/gitlab_flow.md +[idea to production]: https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab +[issue closing pattern]: issues/automatic_issue_closing.md +[permissions]: ../permissions.md +[yml]: ../../ci/yaml/README.md diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md new file mode 100644 index 00000000000..ea7496af089 --- /dev/null +++ b/doc/user/project/description_templates.md @@ -0,0 +1,42 @@ +# Description templates + +>[Introduced][ce-4981] in GitLab 8.11. + +Description templates allow you to define context-specific templates for issue +and merge request description fields for your project. + +## Overview + +By using the description templates, users that create a new issue or merge +request can select a description template to help them communicate with other +contributors effectively. + +Every GitLab project can define its own set of description templates as they +are added to the root directory of a GitLab project's repository. + +Description templates must be written in [Markdown](../markdown.md) and stored +in your project's repository under a directory named `.gitlab`. Only the +templates of the default branch will be taken into account. + +## Creating issue templates + +Create a new Markdown (`.md`) file inside the `.gitlab/issue_templates/` +directory in your repository. Commit and push to your default branch. + +## Creating merge request templates + +Similarly to issue templates, create a new Markdown (`.md`) file inside the +`.gitlab/merge_request_templates/` directory in your repository. Commit and +push to your default branch. + +## Using the templates + +Let's take for example that you've created the file `.gitlab/issue_templates/Bug.md`. +This will enable the `Bug` dropdown option when creating or editing issues. When +`Bug` is selected, the content from the `Bug.md` template file will be copied +to the issue description field. The 'Reset template' button will discard any +changes you made after picking the template and return it to its initial status. + + + +[ce-4981]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4981 diff --git a/doc/user/project/git_attributes.md b/doc/user/project/git_attributes.md new file mode 100644 index 00000000000..21ef94e61f7 --- /dev/null +++ b/doc/user/project/git_attributes.md @@ -0,0 +1,22 @@ +# Git Attributes + +GitLab supports defining custom [Git attributes][gitattributes] such as what +files to treat as binary, and what language to use for syntax highlighting +diffs. + +To define these attributes, create a file called `.gitattributes` in the root +directory of your repository and push it to the default branch of your project. + +## Encoding Requirements + +The `.gitattributes` file _must_ be encoded in UTF-8 and _must not_ contain a +Byte Order Mark. If a different encoding is used, the file's contents will be +ignored. + +## Syntax Highlighting + +The `.gitattributes` file can be used to define which language to use when +syntax highlighting files and diffs. See ["Syntax +Highlighting"](highlighting.md) for more information. + +[gitattributes]: https://git-scm.com/docs/gitattributes diff --git a/doc/user/project/img/container_registry_enable.png b/doc/user/project/img/container_registry_enable.png Binary files differnew file mode 100644 index 00000000000..6fffa2a91d8 --- /dev/null +++ b/doc/user/project/img/container_registry_enable.png diff --git a/doc/user/project/img/container_registry_panel.png b/doc/user/project/img/container_registry_panel.png Binary files differnew file mode 100644 index 00000000000..60fd76192b7 --- /dev/null +++ b/doc/user/project/img/container_registry_panel.png diff --git a/doc/user/project/img/container_registry_tab.png b/doc/user/project/img/container_registry_tab.png Binary files differnew file mode 100644 index 00000000000..36b883aaa97 --- /dev/null +++ b/doc/user/project/img/container_registry_tab.png diff --git a/doc/user/project/img/cycle_analytics_landing_page.png b/doc/user/project/img/cycle_analytics_landing_page.png Binary files differnew file mode 100644 index 00000000000..b212134d5ed --- /dev/null +++ b/doc/user/project/img/cycle_analytics_landing_page.png diff --git a/doc/user/project/img/description_templates.png b/doc/user/project/img/description_templates.png Binary files differnew file mode 100644 index 00000000000..c41cc77a94c --- /dev/null +++ b/doc/user/project/img/description_templates.png diff --git a/doc/user/project/img/issue_board.png b/doc/user/project/img/issue_board.png Binary files differnew file mode 100644 index 00000000000..63c269f6dbc --- /dev/null +++ b/doc/user/project/img/issue_board.png diff --git a/doc/user/project/img/issue_board_add_list.png b/doc/user/project/img/issue_board_add_list.png Binary files differnew file mode 100644 index 00000000000..2b8c10eaa0a --- /dev/null +++ b/doc/user/project/img/issue_board_add_list.png diff --git a/doc/user/project/img/issue_board_search_backlog.png b/doc/user/project/img/issue_board_search_backlog.png Binary files differnew file mode 100644 index 00000000000..112ea171539 --- /dev/null +++ b/doc/user/project/img/issue_board_search_backlog.png diff --git a/doc/user/project/img/issue_board_system_notes.png b/doc/user/project/img/issue_board_system_notes.png Binary files differnew file mode 100644 index 00000000000..b69ef034954 --- /dev/null +++ b/doc/user/project/img/issue_board_system_notes.png diff --git a/doc/user/project/img/issue_board_welcome_message.png b/doc/user/project/img/issue_board_welcome_message.png Binary files differnew file mode 100644 index 00000000000..b757faeb230 --- /dev/null +++ b/doc/user/project/img/issue_board_welcome_message.png diff --git a/doc/user/project/img/koding_build-in-progress.png b/doc/user/project/img/koding_build-in-progress.png Binary files differnew file mode 100644 index 00000000000..f8cc81834c4 --- /dev/null +++ b/doc/user/project/img/koding_build-in-progress.png diff --git a/doc/user/project/img/koding_build-logs.png b/doc/user/project/img/koding_build-logs.png Binary files differnew file mode 100644 index 00000000000..a04cd5aff99 --- /dev/null +++ b/doc/user/project/img/koding_build-logs.png diff --git a/doc/user/project/img/koding_build-success.png b/doc/user/project/img/koding_build-success.png Binary files differnew file mode 100644 index 00000000000..2a0dd296480 --- /dev/null +++ b/doc/user/project/img/koding_build-success.png diff --git a/doc/user/project/img/koding_commit-koding.yml.png b/doc/user/project/img/koding_commit-koding.yml.png Binary files differnew file mode 100644 index 00000000000..3e133c50327 --- /dev/null +++ b/doc/user/project/img/koding_commit-koding.yml.png diff --git a/doc/user/project/img/koding_different-stack-on-mr-try.png b/doc/user/project/img/koding_different-stack-on-mr-try.png Binary files differnew file mode 100644 index 00000000000..fd25e32f648 --- /dev/null +++ b/doc/user/project/img/koding_different-stack-on-mr-try.png diff --git a/doc/user/project/img/koding_edit-on-ide.png b/doc/user/project/img/koding_edit-on-ide.png Binary files differnew file mode 100644 index 00000000000..fd5aaff75f5 --- /dev/null +++ b/doc/user/project/img/koding_edit-on-ide.png diff --git a/doc/user/project/img/koding_enable-koding.png b/doc/user/project/img/koding_enable-koding.png Binary files differnew file mode 100644 index 00000000000..c0ae0ee9918 --- /dev/null +++ b/doc/user/project/img/koding_enable-koding.png diff --git a/doc/user/project/img/koding_landing.png b/doc/user/project/img/koding_landing.png Binary files differnew file mode 100644 index 00000000000..7c629d9b05e --- /dev/null +++ b/doc/user/project/img/koding_landing.png diff --git a/doc/user/project/img/koding_open-gitlab-from-koding.png b/doc/user/project/img/koding_open-gitlab-from-koding.png Binary files differnew file mode 100644 index 00000000000..c958cf8f224 --- /dev/null +++ b/doc/user/project/img/koding_open-gitlab-from-koding.png diff --git a/doc/user/project/img/koding_run-in-ide.png b/doc/user/project/img/koding_run-in-ide.png Binary files differnew file mode 100644 index 00000000000..f91ee0f74cc --- /dev/null +++ b/doc/user/project/img/koding_run-in-ide.png diff --git a/doc/user/project/img/koding_run-mr-in-ide.png b/doc/user/project/img/koding_run-mr-in-ide.png Binary files differnew file mode 100644 index 00000000000..502817a2a46 --- /dev/null +++ b/doc/user/project/img/koding_run-mr-in-ide.png diff --git a/doc/user/project/img/koding_set-up-ide.png b/doc/user/project/img/koding_set-up-ide.png Binary files differnew file mode 100644 index 00000000000..7f408c980b5 --- /dev/null +++ b/doc/user/project/img/koding_set-up-ide.png diff --git a/doc/user/project/img/koding_stack-import.png b/doc/user/project/img/koding_stack-import.png Binary files differnew file mode 100644 index 00000000000..2a4e3c87fc8 --- /dev/null +++ b/doc/user/project/img/koding_stack-import.png diff --git a/doc/user/project/img/koding_start-build.png b/doc/user/project/img/koding_start-build.png Binary files differnew file mode 100644 index 00000000000..52159440f62 --- /dev/null +++ b/doc/user/project/img/koding_start-build.png diff --git a/doc/container_registry/img/mitmproxy-docker.png b/doc/user/project/img/mitmproxy-docker.png Binary files differindex 4e3e37b413d..4e3e37b413d 100644 --- a/doc/container_registry/img/mitmproxy-docker.png +++ b/doc/user/project/img/mitmproxy-docker.png diff --git a/doc/user/project/img/protected_branches_devs_can_push.png b/doc/user/project/img/protected_branches_devs_can_push.png Binary files differindex 9c33db36586..812cc8767b7 100644 --- a/doc/user/project/img/protected_branches_devs_can_push.png +++ b/doc/user/project/img/protected_branches_devs_can_push.png diff --git a/doc/user/project/img/protected_branches_list.png b/doc/user/project/img/protected_branches_list.png Binary files differindex 9f070f7a208..f33f1b2bdb6 100644 --- a/doc/user/project/img/protected_branches_list.png +++ b/doc/user/project/img/protected_branches_list.png diff --git a/doc/user/project/img/protected_branches_page.png b/doc/user/project/img/protected_branches_page.png Binary files differnew file mode 100644 index 00000000000..1585dde5b29 --- /dev/null +++ b/doc/user/project/img/protected_branches_page.png diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md new file mode 100644 index 00000000000..4a6c0d88241 --- /dev/null +++ b/doc/user/project/issue_board.md @@ -0,0 +1,188 @@ +# Issue board + +> [Introduced][ce-5554] in GitLab 8.11. + +The GitLab Issue Board is a software project management tool used to plan, +organize, and visualize a workflow for a feature or product release. +It can be seen like a light version of a [Kanban] or a [Scrum] board. + +Other interesting links: + +- [GitLab Issue Board landing page on about.gitlab.com][landing] +- [YouTube video introduction to Issue Boards][youtube] + +## Overview + +The Issue Board builds on GitLab's existing issue tracking functionality and +leverages the power of [labels] by utilizing them as lists of the scrum board. + +With the Issue Board you can have a different view of your issues while also +maintaining the same filtering and sorting abilities you see across the +issue tracker. + +Below is a table of the definitions used for GitLab's Issue Board. + +| What we call it | What it means | +| -------------- | ------------- | +| **Issue Board** | It represents a different view for your issues. It can have multiple lists with each list consisting of issues represented by cards. | +| **List** | Each label that exists in the issue tracker can have its own dedicated list. Every list is named after the label it is based on and is represented by a column which contains all the issues associated with that label. You can think of a list like the results you get when you filter the issues by a label in your issue tracker. | +| **Card** | Every card represents an issue and it is shown under the list for which it has a label. The information you can see on a card consists of the issue number, the issue title, the assignee and the labels associated with it. You can drag cards around from one list to another. Issues inside lists are [ordered by priority](labels.md#prioritize-labels). | + +There are three types of lists, the ones you create based on your labels, and +two default: + +- **Backlog** (default): shows all issues that do not fall in one of the other lists. Always appears on the very left. +- **Done** (default): shows all closed issues. Always appears on the very right. +Label list: a list based on a label. It shows all issues with that label. +- Label list: a list based on a label. It shows all opened issues with that label. + + + +--- + +In short, here's a list of actions you can take in an Issue Board: + +- [Create a new list](#creating-a-new-list). +- [Delete an existing list](#deleting-a-list). +- Drag issues between lists. +- Drag and reorder the lists themselves. +- Change issue labels on-the-fly while dragging issues between lists. +- Close an issue if you drag it to the **Done** list. +- Create a new list from a non-existing label by [creating the label on-the-fly](#creating-a-new-list) + within the Issue Board. +- [Filter issues](#filtering-issues) that appear across your Issue Board. + +If you are not able to perform one or more of the things above, make sure you +have the right [permissions](#permissions). + +## First time using the Issue Board + +The first time you navigate to your Issue Board, you will be presented with the +two default lists (**Backlog** and **Done**) and a welcoming message that gives +you two options. You can either create a predefined set of labels and create +their corresponding lists to the Issue Board or opt-out and use your own lists. + + + +If you choose to use and create the predefined lists, they will appear as empty +because the labels associated to them will not exist up until that moment, +which means the system has no way of populating them automatically. That's of +course if the predefined labels don't already exist. If any of them does exist, +the list will be created and filled with the issues that have that label. + +## Creating a new list + +Create a new list by clicking on the **Create new list** button at the upper +right corner of the Issue Board. + + + +Simply choose the label to create the list from. The new list will be inserted +at the end of the lists, before **Done**. Moving and reordering lists is as +easy as dragging them around. + +To create a list for a label that doesn't yet exist, simply create the label by +choosing **Create new label**. The label will be created on-the-fly and it will +be immediately added to the dropdown. You can now choose it to create a list. + +## Deleting a list + +To delete a list from the Issue Board use the small trash icon that is present +in the list's heading. A confirmation dialog will appear for you to confirm. + +Deleting a list doesn't have any effect in issues and labels, it's just the +list view that is removed. You can always add it back later if you need. + +## Searching issues in the Backlog list + +The very first time you start using the Issue Board, it is very likely your +issue tracker is already populated with labels and issues. In that case, +**Backlog** will have all the issues that don't belong to another list, and +**Done** will have all the closed ones. + +For performance and visibility reasons, each list shows the first 20 issues +by default. If you have more than 20, you have to start scrolling down for the +next 20 issues to appear. This can be cumbersome if your issue tracker hosts +hundreds of issues, and for that reason it is easier to search for issues to +move from **Backlog** to another list. + +Start typing in the search bar under the **Backlog** list and the relevant +issues will appear. + + + +## Filtering issues + +You should be able to use the filters on top of your Issue Board to show only +the results you want. This is similar to the filtering used in the issue tracker +since the metadata from the issues and labels are re-used in the Issue Board. + +You can filter by author, assignee, milestone and label. + +## Creating workflows + +By reordering your lists, you can create workflows. As lists in Issue Boards are +based on labels, it works out of the box with your existing issues. So if you've +already labeled things with 'Backend' and 'Frontend', the issue will appear in +the lists as you create them. In addition, this means you can easily move +something between lists by changing a label. + +A typical workflow of using the Issue Board would be: + +1. You have [created][create-labels] and [prioritized][label-priority] labels + so that you can easily categorize your issues. +1. You have a bunch of issues (ideally labeled). +1. You visit the Issue Board and start [creating lists](#creating-a-new-list) to + create a workflow. +1. You move issues around in lists so that your team knows who should be working + on what issue. +1. When the work by one team is done, the issue can be dragged to the next list + so someone else can pick up. +1. When the issue is finally resolved, the issue is moved to the **Done** list + and gets automatically closed. + +For instance you can create a list based on the label of 'Frontend' and one for +'Backend'. A designer can start working on an issue by dragging it from +**Backlog** to 'Frontend'. That way, everyone knows that this issue is now being +worked on by the designers. Then, once they're done, all they have to do is +drag it over to the next list, 'Backend', where a backend developer can +eventually pick it up. Once they’re done, they move it to **Done**, to close the +issue. + +This process can be seen clearly when visiting an issue since with every move +to another list the label changes and a system not is recorded. + + + +## Permissions + +[Developers and up](../permissions.md) can use all the functionality of the +Issue Board, that is create/delete lists and drag issues around. + +## Tips + +A few things to remember: + +- The label that corresponds to a list is hidden for issues under that list. +- Moving an issue between lists removes the label from the list it came from + and adds the label from the list it goes to. +- When moving a card to **Done**, the label of the list it came from is removed + and the issue gets closed. +- An issue can exist in multiple lists if it has more than one label. +- Lists are populated with issues automatically if the issues are labeled. +- Clicking on the issue title inside a card will take you to that issue. +- Clicking on a label inside a card will quickly filter the entire Issue Board + and show only the issues from all lists that have that label. +- Issues inside lists are [ordered by priority][label-priority]. +- For performance and visibility reasons, each list shows the first 20 issues + by default. If you have more than 20 issues start scrolling down and the next + 20 will appear. + +[ce-5554]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5554 +[labels]: ./labels.md +[scrum]: https://en.wikipedia.org/wiki/Scrum_(software_development) +[kanban]: https://en.wikipedia.org/wiki/Kanban_(development) +[create-labels]: ./labels.md#create-new-labels +[label-priority]: ./labels.md#prioritize-labels +[landing]: https://about.gitlab.com/solutions/issueboard +[youtube]: https://www.youtube.com/watch?v=UWsJ8tkHAa8 diff --git a/doc/user/project/issues/automatic_issue_closing.md b/doc/user/project/issues/automatic_issue_closing.md new file mode 100644 index 00000000000..d6f3a7d5555 --- /dev/null +++ b/doc/user/project/issues/automatic_issue_closing.md @@ -0,0 +1,55 @@ +# Automatic issue closing + +>**Note:** +This is the user docs. In order to change the default issue closing pattern, +follow the steps in the [administration docs]. + +When a commit or merge request resolves one or more issues, it is possible to +automatically have these issues closed when the commit or merge request lands +in the project's default branch. + +If a commit message or merge request description contains a sentence matching +a certain regular expression, all issues referenced from the matched text will +be closed. This happens when the commit is pushed to a project's **default** +branch, or when a commit or merge request is merged into it. + +## Default closing pattern value + +When not specified, the default issue closing pattern as shown below will be +used: + +```bash +((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+) +``` + +Note that `%{issue_ref}` is a complex regular expression defined inside GitLab's +source code that can match a reference to 1) a local issue (`#123`), +2) a cross-project issue (`group/project#123`) or 3) a link to an issue +(`https://gitlab.example.com/group/project/issues/123`). + +--- + +This translates to the following keywords: + +- Close, Closes, Closed, Closing, close, closes, closed, closing +- Fix, Fixes, Fixed, Fixing, fix, fixes, fixed, fixing +- Resolve, Resolves, Resolved, Resolving, resolve, resolves, resolved, resolving + +--- + +For example the following commit message: + +``` +Awesome commit message + +Fix #20, Fixes #21 and Closes group/otherproject#22. +This commit is also related to #17 and fixes #18, #19 +and https://gitlab.example.com/group/otherproject/issues/23. +``` + +will close `#18`, `#19`, `#20`, and `#21` in the project this commit is pushed +to, as well as `#22` and `#23` in group/otherproject. `#17` won't be closed as +it does not match the pattern. It works with multi-line commit messages as well +as one-liners when used with `git commit -m`. + +[administration docs]: ../../../administration/issue_closing_pattern.md diff --git a/doc/user/project/koding.md b/doc/user/project/koding.md new file mode 100644 index 00000000000..c56a1efe3c2 --- /dev/null +++ b/doc/user/project/koding.md @@ -0,0 +1,128 @@ +# Koding & GitLab + +> [Introduced][ce-5909] in GitLab 8.11. + +This document will guide you through using Koding integration on GitLab in +detail. For configuring and installing please follow the +[administrator guide](../../administration/integration/koding.md). + +You can use Koding integration to run and develop your projects on GitLab. This +will allow you and the users to test your project without leaving the browser. +Koding handles projects as stacks which are basic recipes to define your +environment for your project. With this integration you can automatically +create a proper stack template for your projects. Currently auto-generated +stack templates are designed to work with AWS which requires a valid AWS +credential to be able to use these stacks. You can find more information about +stacks and the other providers that you can use on Koding following the +[Koding documentation][koding-docs]. + +## Enable Integration + +You can enable Koding integration by providing the running Koding instance URL +in Application Settings under **Admin area > Settings** (`/admin/application_settings`). + + + +Once enabled you will see `Koding` link on your sidebar which leads you to +Koding Landing page. + + + +You can navigate to running Koding instance from here. For more information and +details about configuring the integration, please follow the +[administrator guide](../../administration/integration/koding.md). + +## Set up Koding on Projects + +Once it's enabled, you will see some integration buttons on Project pages, +Merge Requests etc. To get started working on a specific project you first need +to create a `.koding.yml` file under your project root. You can easily do that +by using `Set Up Koding` button which will be visible on every project's +landing page; + + + +Once you click this will open a New File page on GitLab with auto-generated +`.koding.yml` content based on your server and repository configuration. + + + + +## Run a project on Koding + +If there is `.koding.yml` exists in your project root, you will see +`Run in IDE (Koding)` button in your project landing page. You can initiate the +process from here. + + + +This will open Koding defined in the settings in a new window and will start +importing the project's stack file. + + + +You should see the details of your repository imported into your Koding +instance. Once it's completed it will lead you to the Stack Editor and from +there you can start using your new stack integrated with your project on your +GitLab instance. For details about what's next you can follow +[this guide](https://www.koding.com/docs/creating-an-aws-stack) from step 8. + +Once stack initialized you will see the `README.md` content from your project +in `Stack Build` wizard, this wizard will let you build the stack and import +your project into it. **Once it's completed it will automatically open the +related vm instead of importing from scratch**. + + + +This will take time depending on the required environment. + + + +It usually takes ~4 min. to make it ready with a `t2.nano` instance on given +AWS region. (`t2.nano` is default vm type on auto-generated stack template +which can be manually changed). + + + +You can check out the `Build Logs` from this success modal as well. + + + +You can now `Start Coding`! + + + +## Try a Merge Request on IDE + +It's also possible to try a change on IDE before merging it. This flow only +enabled if the target project has `.koding.yml` in it's target branch. You +should see the alternative version of `Run in IDE (Koding)` button in merge +request pages as well; + + + +This will again take you to Koding with proper arguments passed, which will +allow Koding to modify the stack template provided by target branch. You can +see the difference; + + + +The flow for the branch stack is also same with the regular project flow. + +## Open GitLab from Koding + +Since stacks generated with import flow defined in previous steps, they have +information about the repository they are belonging to. By using this +information you can access to related GitLab page from stacks on your sidebar +on Koding. + + + +## Other links + +- [YouTube video on GitLab + Koding workflow][youtube] +- [Koding documentation][koding-docs] + +[ce-5909]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5909 +[youtube]: https://youtu.be/3wei5yv_Ye8 +[koding-docs]: https://www.koding.com/docs diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index 4258185b7d0..cf1d9cbe69c 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -1,8 +1,8 @@ # Labels Labels provide an easy way to categorize the issues or merge requests based on -descriptive titles like `bug`, `documentation` or any other text you feel like -it. They can have different colors, a description, and are visible throughout +descriptive titles like `bug`, `documentation` or any other text you feel like. +They can have different colors, a description, and are visible throughout the issue tracker or inside each issue individually. With labels, you can navigate the issue tracker and filter any bloated @@ -22,34 +22,47 @@ created yet.  +Creating a new label from scratch is as easy as pressing the **New label** +button. From there on you can choose the name, give it an optional description, +a color and you are set. + +When you are ready press the **Create label** button to create the new label. + + + --- -You can skip that and create a new label or click that link and GitLab will -generate a set of predefined labels for you. There 8 default generated labels +## Default Labels + +It's possible to populate the labels for your project from a set of predefined labels. + +### Generate GitLab's predefined label set + + + +Click the link to 'Generate a default set of labels' and GitLab will +generate a set of predefined labels for you. There are 8 default generated labels in total and you can see them in the screenshot below.  --- -You can see that from the labels page you can have an overview of the number of -issues and merge requests assigned to each label. - -Creating a new label from scratch is as easy as pressing the **New label** -button. From there on you can choose the name, give it an optional description, -a color and you are set. +## Labels Overview -When you are ready press the **Create label** button to create the new label. + - +You can see that from the labels page you can have an overview of the number of +issues and merge requests assigned to each label. ## Prioritize labels >**Notes:** - - This feature was introduced in GitLab 8.9. - - Priority sorting is based on the highest priority label only. This might - change in the future, follow the discussion in - https://gitlab.com/gitlab-org/gitlab-ce/issues/18554. +> +> - Introduced in GitLab 8.9. +> - Priority sorting is based on the highest priority label only. This might +> change in the future, follow the discussion in +> https://gitlab.com/gitlab-org/gitlab-ce/issues/18554. Prioritized labels are like any other label, but sorted by priority. This allows you to sort issues and merge requests by priority. @@ -87,8 +100,7 @@ important. ## Create a new label right from the issue tracker ->**Note:** -This feature was introduced in GitLab 8.6. +> Introduced in GitLab 8.6. There are times when you are already in the issue tracker searching for a label, only to realize it doesn't exist. Instead of going to the **Labels** diff --git a/doc/user/project/merge_requests.md b/doc/user/project/merge_requests.md new file mode 100644 index 00000000000..5af9a5d049c --- /dev/null +++ b/doc/user/project/merge_requests.md @@ -0,0 +1,169 @@ +# Merge Requests + +Merge requests allow you to exchange changes you made to source code and +collaborate with other people on the same project. + +## Authorization for merge requests + +There are two main ways to have a merge request flow with GitLab: + +1. Working with [protected branches][] in a single repository +1. Working with forks of an authoritative project + +[Learn more about the authorization for merge requests.](merge_requests/authorization_for_merge_requests.md) + +## Cherry-pick changes + +Cherry-pick any commit in the UI by simply clicking the **Cherry-pick** button +in a merged merge requests or a commit. + +[Learn more about cherry-picking changes.](merge_requests/cherry_pick_changes.md) + +## Merge when build succeeds + +When reviewing a merge request that looks ready to merge but still has one or +more CI builds running, you can set it to be merged automatically when all +builds succeed. This way, you don't have to wait for the builds to finish and +remember to merge the request manually. + +[Learn more about merging when build succeeds.](merge_requests/merge_when_build_succeeds.md) + +## Resolve discussion comments in merge requests reviews + +Keep track of the progress during a code review with resolving comments. +Resolving comments prevents you from forgetting to address feedback and lets +you hide discussions that are no longer relevant. + +[Read more about resolving discussion comments in merge requests reviews.](merge_requests/merge_request_discussion_resolution.md) + +## Resolve conflicts + +When a merge request has conflicts, GitLab may provide the option to resolve +those conflicts in the GitLab UI. + +[Learn more about resolving merge conflicts in the UI.](merge_requests/resolve_conflicts.md) + +## Revert changes + +GitLab implements Git's powerful feature to revert any commit with introducing +a **Revert** button in merge requests and commit details. + +[Learn more about reverting changes in the UI](merge_requests/revert_changes.md) + +## Merge requests versions + +Every time you push to a branch that is tied to a merge request, a new version +of merge request diff is created. When you visit a merge request that contains +more than one pushes, you can select and compare the versions of those merge +request diffs. + +[Read more about the merge requests versions.](merge_requests/versions.md) + +## Work In Progress merge requests + +To prevent merge requests from accidentally being accepted before they're +completely ready, GitLab blocks the "Accept" button for merge requests that +have been marked as a **Work In Progress**. + +[Learn more about settings a merge request as "Work In Progress".](merge_requests/work_in_progress_merge_requests.md) + +## Ignore whitespace changes in Merge Request diff view + +If you click the **Hide whitespace changes** button, you can see the diff +without whitespace changes (if there are any). This is also working when on a +specific commit page. + + + +>**Tip:** +You can append `?w=1` while on the diffs page of a merge request to ignore any +whitespace changes. + +## Tips + +Here are some tips that will help you be more efficient with merge requests in +the command line. + +> **Note:** +This section might move in its own document in the future. + +### Checkout merge requests locally + +A merge request contains all the history from a repository, plus the additional +commits added to the branch associated with the merge request. Here's a few +tricks to checkout a merge request locally. + +Please note that you can checkout a merge request locally even if the source +project is a fork (even a private fork) of the target project. + +#### Checkout locally by adding a git alias + +Add the following alias to your `~/.gitconfig`: + +``` +[alias] + mr = !sh -c 'git fetch $1 merge-requests/$2/head:mr-$1-$2 && git checkout mr-$1-$2' - +``` + +Now you can check out a particular merge request from any repository and any +remote. For example, to check out the merge request with ID 5 as shown in GitLab +from the `upstream` remote, do: + +``` +git mr upstream 5 +``` + +This will fetch the merge request into a local `mr-upstream-5` branch and check +it out. + +#### Checkout locally by modifying `.git/config` for a given repository + +Locate the section for your GitLab remote in the `.git/config` file. It looks +like this: + +``` +[remote "origin"] + url = https://gitlab.com/gitlab-org/gitlab-ce.git + fetch = +refs/heads/*:refs/remotes/origin/* +``` + +You can open the file with: + +``` +git config -e +``` + +Now add the following line to the above section: + +``` +fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* +``` + +In the end, it should look like this: + +``` +[remote "origin"] + url = https://gitlab.com/gitlab-org/gitlab-ce.git + fetch = +refs/heads/*:refs/remotes/origin/* + fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* +``` + +Now you can fetch all the merge requests: + +``` +git fetch origin + +... +From https://gitlab.com/gitlab-org/gitlab-ce.git + * [new ref] refs/merge-requests/1/head -> origin/merge-requests/1 + * [new ref] refs/merge-requests/2/head -> origin/merge-requests/2 +... +``` + +And to check out a particular merge request: + +``` +git checkout origin/merge-requests/1 +``` + +[protected branches]: protected_branches.md diff --git a/doc/user/project/merge_requests/authorization_for_merge_requests.md b/doc/user/project/merge_requests/authorization_for_merge_requests.md new file mode 100644 index 00000000000..59b3fe7242c --- /dev/null +++ b/doc/user/project/merge_requests/authorization_for_merge_requests.md @@ -0,0 +1,56 @@ +# Authorization for Merge requests + +There are two main ways to have a merge request flow with GitLab: + +1. Working with [protected branches] in a single repository. +1. Working with forks of an authoritative project. + +## Protected branch flow + +With the protected branch flow everybody works within the same GitLab project. + +The project maintainers get Master access and the regular developers get +Developer access. + +The maintainers mark the authoritative branches as 'Protected'. + +The developers push feature branches to the project and create merge requests +to have their feature branches reviewed and merged into one of the protected +branches. + +By default, only users with Master access can merge changes into a protected +branch. + +**Advantages** + +- Fewer projects means less clutter. +- Developers need to consider only one remote repository. + +**Disadvantages** + +- Manual setup of protected branch required for each new project + +## Forking workflow + +With the forking workflow the maintainers get Master access and the regular +developers get Reporter access to the authoritative repository, which prohibits +them from pushing any changes to it. + +Developers create forks of the authoritative project and push their feature +branches to their own forks. + +To get their changes into master they need to create a merge request across +forks. + +**Advantages** + +- In an appropriately configured GitLab group, new projects automatically get + the required access restrictions for regular developers: fewer manual steps + to configure authorization for new projects. + +**Disadvantages** + +- The project need to keep their forks up to date, which requires more advanced + Git skills (managing multiple remotes). + +[protected branches]: ../protected_branches.md diff --git a/doc/user/project/merge_requests/cherry_pick_changes.md b/doc/user/project/merge_requests/cherry_pick_changes.md new file mode 100644 index 00000000000..64b94d81024 --- /dev/null +++ b/doc/user/project/merge_requests/cherry_pick_changes.md @@ -0,0 +1,52 @@ +# Cherry-pick changes + +> [Introduced][ce-3514] in GitLab 8.7. + +--- + +GitLab implements Git's powerful feature to [cherry-pick any commit][git-cherry-pick] +with introducing a **Cherry-pick** button in Merge Requests and commit details. + +## Cherry-picking a Merge Request + +After the Merge Request has been merged, a **Cherry-pick** button will be available +to cherry-pick the changes introduced by that Merge Request: + + + +--- + +You can cherry-pick the changes directly into the selected branch or you can opt to +create a new Merge Request with the cherry-pick changes: + + + +## Cherry-picking a Commit + +You can cherry-pick a Commit from the Commit details page: + + + +--- + +Similar to cherry-picking a Merge Request, you can opt to cherry-pick the changes +directly into the target branch or create a new Merge Request to cherry-pick the +changes: + + + +--- + +Please note that when cherry-picking merge commits, the mainline will always be the +first parent. If you want to use a different mainline then you need to do that +from the command line. + +Here is a quick example to cherry-pick a merge commit using the second parent as the +mainline: + +```bash +git cherry-pick -m 2 7a39eb0 +``` + +[ce-3514]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3514 "Cherry-pick button Merge Request" +[git-cherry-pick]: https://git-scm.com/docs/git-cherry-pick "Git cherry-pick documentation" diff --git a/doc/workflow/img/cherry_pick_changes_commit.png b/doc/user/project/merge_requests/img/cherry_pick_changes_commit.png Binary files differindex 7fb68cc9e9b..7fb68cc9e9b 100644 --- a/doc/workflow/img/cherry_pick_changes_commit.png +++ b/doc/user/project/merge_requests/img/cherry_pick_changes_commit.png diff --git a/doc/workflow/img/cherry_pick_changes_commit_modal.png b/doc/user/project/merge_requests/img/cherry_pick_changes_commit_modal.png Binary files differindex 5267e04562f..5267e04562f 100644 --- a/doc/workflow/img/cherry_pick_changes_commit_modal.png +++ b/doc/user/project/merge_requests/img/cherry_pick_changes_commit_modal.png diff --git a/doc/workflow/img/cherry_pick_changes_mr.png b/doc/user/project/merge_requests/img/cherry_pick_changes_mr.png Binary files differindex 975fb13e463..975fb13e463 100644 --- a/doc/workflow/img/cherry_pick_changes_mr.png +++ b/doc/user/project/merge_requests/img/cherry_pick_changes_mr.png diff --git a/doc/workflow/img/cherry_pick_changes_mr_modal.png b/doc/user/project/merge_requests/img/cherry_pick_changes_mr_modal.png Binary files differindex 6c003bacbe3..6c003bacbe3 100644 --- a/doc/workflow/img/cherry_pick_changes_mr_modal.png +++ b/doc/user/project/merge_requests/img/cherry_pick_changes_mr_modal.png diff --git a/doc/workflow/merge_requests/commit_compare.png b/doc/user/project/merge_requests/img/commit_compare.png Binary files differindex 0e4a2b23c04..0e4a2b23c04 100644 --- a/doc/workflow/merge_requests/commit_compare.png +++ b/doc/user/project/merge_requests/img/commit_compare.png diff --git a/doc/user/project/merge_requests/img/conflict_section.png b/doc/user/project/merge_requests/img/conflict_section.png Binary files differnew file mode 100644 index 00000000000..842e50b14b2 --- /dev/null +++ b/doc/user/project/merge_requests/img/conflict_section.png diff --git a/doc/user/project/merge_requests/img/discussion_view.png b/doc/user/project/merge_requests/img/discussion_view.png Binary files differnew file mode 100644 index 00000000000..83bb60acce2 --- /dev/null +++ b/doc/user/project/merge_requests/img/discussion_view.png diff --git a/doc/user/project/merge_requests/img/discussions_resolved.png b/doc/user/project/merge_requests/img/discussions_resolved.png Binary files differnew file mode 100644 index 00000000000..85428129ac8 --- /dev/null +++ b/doc/user/project/merge_requests/img/discussions_resolved.png diff --git a/doc/user/project/merge_requests/img/merge_request_diff.png b/doc/user/project/merge_requests/img/merge_request_diff.png Binary files differnew file mode 100644 index 00000000000..06ee4908edc --- /dev/null +++ b/doc/user/project/merge_requests/img/merge_request_diff.png diff --git a/doc/user/project/merge_requests/img/merge_request_widget.png b/doc/user/project/merge_requests/img/merge_request_widget.png Binary files differnew file mode 100644 index 00000000000..ffb96b17b07 --- /dev/null +++ b/doc/user/project/merge_requests/img/merge_request_widget.png diff --git a/doc/workflow/merge_when_build_succeeds/enable.png b/doc/user/project/merge_requests/img/merge_when_build_succeeds_enable.png Binary files differindex b86e6d7b3fd..b86e6d7b3fd 100644 --- a/doc/workflow/merge_when_build_succeeds/enable.png +++ b/doc/user/project/merge_requests/img/merge_when_build_succeeds_enable.png diff --git a/doc/user/project/merge_requests/img/merge_when_build_succeeds_only_if_succeeds_msg.png b/doc/user/project/merge_requests/img/merge_when_build_succeeds_only_if_succeeds_msg.png Binary files differnew file mode 100644 index 00000000000..6b9756b7418 --- /dev/null +++ b/doc/user/project/merge_requests/img/merge_when_build_succeeds_only_if_succeeds_msg.png diff --git a/doc/workflow/merge_requests/only_allow_merge_if_build_succeeds.png b/doc/user/project/merge_requests/img/merge_when_build_succeeds_only_if_succeeds_settings.png Binary files differindex 18bebf5fe92..18bebf5fe92 100644 --- a/doc/workflow/merge_requests/only_allow_merge_if_build_succeeds.png +++ b/doc/user/project/merge_requests/img/merge_when_build_succeeds_only_if_succeeds_settings.png diff --git a/doc/workflow/merge_when_build_succeeds/status.png b/doc/user/project/merge_requests/img/merge_when_build_succeeds_status.png Binary files differindex f3ea61d8147..f3ea61d8147 100644 --- a/doc/workflow/merge_when_build_succeeds/status.png +++ b/doc/user/project/merge_requests/img/merge_when_build_succeeds_status.png diff --git a/doc/user/project/merge_requests/img/resolve_comment_button.png b/doc/user/project/merge_requests/img/resolve_comment_button.png Binary files differnew file mode 100644 index 00000000000..2c4ab2f5d53 --- /dev/null +++ b/doc/user/project/merge_requests/img/resolve_comment_button.png diff --git a/doc/user/project/merge_requests/img/resolve_discussion_button.png b/doc/user/project/merge_requests/img/resolve_discussion_button.png Binary files differnew file mode 100644 index 00000000000..73f265bb101 --- /dev/null +++ b/doc/user/project/merge_requests/img/resolve_discussion_button.png diff --git a/doc/workflow/img/revert_changes_commit.png b/doc/user/project/merge_requests/img/revert_changes_commit.png Binary files differindex e7194fc3504..e7194fc3504 100644 --- a/doc/workflow/img/revert_changes_commit.png +++ b/doc/user/project/merge_requests/img/revert_changes_commit.png diff --git a/doc/workflow/img/revert_changes_commit_modal.png b/doc/user/project/merge_requests/img/revert_changes_commit_modal.png Binary files differindex c660ec7eaec..c660ec7eaec 100644 --- a/doc/workflow/img/revert_changes_commit_modal.png +++ b/doc/user/project/merge_requests/img/revert_changes_commit_modal.png diff --git a/doc/workflow/img/revert_changes_mr.png b/doc/user/project/merge_requests/img/revert_changes_mr.png Binary files differindex 3002f0ac1c5..3002f0ac1c5 100644 --- a/doc/workflow/img/revert_changes_mr.png +++ b/doc/user/project/merge_requests/img/revert_changes_mr.png diff --git a/doc/workflow/img/revert_changes_mr_modal.png b/doc/user/project/merge_requests/img/revert_changes_mr_modal.png Binary files differindex c6aaeecc8a6..c6aaeecc8a6 100644 --- a/doc/workflow/img/revert_changes_mr_modal.png +++ b/doc/user/project/merge_requests/img/revert_changes_mr_modal.png diff --git a/doc/user/project/merge_requests/img/versions.png b/doc/user/project/merge_requests/img/versions.png Binary files differnew file mode 100644 index 00000000000..6c86f2c68ac --- /dev/null +++ b/doc/user/project/merge_requests/img/versions.png diff --git a/doc/user/project/merge_requests/img/versions_compare.png b/doc/user/project/merge_requests/img/versions_compare.png Binary files differnew file mode 100644 index 00000000000..890cae7768c --- /dev/null +++ b/doc/user/project/merge_requests/img/versions_compare.png diff --git a/doc/user/project/merge_requests/img/versions_dropdown.png b/doc/user/project/merge_requests/img/versions_dropdown.png Binary files differnew file mode 100644 index 00000000000..9bab9304e14 --- /dev/null +++ b/doc/user/project/merge_requests/img/versions_dropdown.png diff --git a/doc/user/project/merge_requests/img/versions_system_note.png b/doc/user/project/merge_requests/img/versions_system_note.png Binary files differnew file mode 100644 index 00000000000..7c9d7715745 --- /dev/null +++ b/doc/user/project/merge_requests/img/versions_system_note.png diff --git a/doc/workflow/wip_merge_requests/blocked_accept_button.png b/doc/user/project/merge_requests/img/wip_blocked_accept_button.png Binary files differindex 89c458aa8d9..89c458aa8d9 100644 --- a/doc/workflow/wip_merge_requests/blocked_accept_button.png +++ b/doc/user/project/merge_requests/img/wip_blocked_accept_button.png diff --git a/doc/workflow/wip_merge_requests/mark_as_wip.png b/doc/user/project/merge_requests/img/wip_mark_as_wip.png Binary files differindex 9c37354a653..9c37354a653 100644 --- a/doc/workflow/wip_merge_requests/mark_as_wip.png +++ b/doc/user/project/merge_requests/img/wip_mark_as_wip.png diff --git a/doc/workflow/wip_merge_requests/unmark_as_wip.png b/doc/user/project/merge_requests/img/wip_unmark_as_wip.png Binary files differindex 31f7326beb0..31f7326beb0 100644 --- a/doc/workflow/wip_merge_requests/unmark_as_wip.png +++ b/doc/user/project/merge_requests/img/wip_unmark_as_wip.png diff --git a/doc/user/project/merge_requests/merge_request_discussion_resolution.md b/doc/user/project/merge_requests/merge_request_discussion_resolution.md new file mode 100644 index 00000000000..2559f5f5250 --- /dev/null +++ b/doc/user/project/merge_requests/merge_request_discussion_resolution.md @@ -0,0 +1,40 @@ +# Merge Request discussion resolution + +> [Introduced][ce-5022] in GitLab 8.11. + +Discussion resolution helps keep track of progress during code review. +Resolving comments prevents you from forgetting to address feedback and lets you +hide discussions that are no longer relevant. + +!["A discussion between two people on a piece of code"][discussion-view] + +Comments and discussions can be resolved by anyone with at least Developer +access to the project, as well as by the author of the merge request. + +## Marking a comment or discussion as resolved + +You can mark a discussion as resolved by clicking the "Resolve discussion" +button at the bottom of the discussion. + +!["Resolve discussion" button][resolve-discussion-button] + +Alternatively, you can mark each comment as resolved individually. + +!["Resolve comment" button][resolve-comment-button] + +## Jumping between unresolved discussions + +When a merge request has a large number of comments it can be difficult to track +what remains unresolved. You can jump between unresolved discussions with the +Jump button next to the Reply field on a discussion. + +You can also jump to the first unresolved discussion from the button next to the +resolved discussions tracker. + +!["3/4 discussions resolved"][discussions-resolved] + +[ce-5022]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5022 +[resolve-discussion-button]: img/resolve_discussion_button.png +[resolve-comment-button]: img/resolve_comment_button.png +[discussion-view]: img/discussion_view.png +[discussions-resolved]: img/discussions_resolved.png diff --git a/doc/user/project/merge_requests/merge_when_build_succeeds.md b/doc/user/project/merge_requests/merge_when_build_succeeds.md new file mode 100644 index 00000000000..c138061fd40 --- /dev/null +++ b/doc/user/project/merge_requests/merge_when_build_succeeds.md @@ -0,0 +1,46 @@ +# Merge When Build Succeeds + +When reviewing a merge request that looks ready to merge but still has one or +more CI builds running, you can set it to be merged automatically when the +builds pipeline succeed. This way, you don't have to wait for the builds to +finish and remember to merge the request manually. + + + +When you hit the "Merge When Build Succeeds" button, the status of the merge +request will be updated to represent the impending merge. If you cannot wait +for the pipeline to succeed and want to merge immediately, this option is +available in the dropdown menu on the right of the main button. + +Both team developers and the author of the merge request have the option to +cancel the automatic merge if they find a reason why it shouldn't be merged +after all. + + + +When the pipeline succeeds, the merge request will automatically be merged. +When the pipeline fails, the author gets a chance to retry any failed builds, +or to push new commits to fix the failure. + +When the builds are retried and succeed on the second try, the merge request +will automatically be merged after all. When the merge request is updated with +new commits, the automatic merge is automatically canceled to allow the new +changes to be reviewed. + +## Only allow merge requests to be merged if the build succeeds + +> **Note:** +You need to have builds configured to enable this feature. + +You can prevent merge requests from being merged if their build did not succeed. + +Navigate to your project's settings page, select the +**Only allow merge requests to be merged if the build succeeds** check box and +hit **Save** for the changes to take effect. + + + +From now on, every time the pipelinefails you will not be able to merge the +merge request from the UI, until you make all relevant builds pass. + + diff --git a/doc/user/project/merge_requests/resolve_conflicts.md b/doc/user/project/merge_requests/resolve_conflicts.md new file mode 100644 index 00000000000..4d7225bd820 --- /dev/null +++ b/doc/user/project/merge_requests/resolve_conflicts.md @@ -0,0 +1,42 @@ +# Merge conflict resolution + +> [Introduced][ce-5479] in GitLab 8.11. + +When a merge request has conflicts, GitLab may provide the option to resolve +those conflicts in the GitLab UI. (See +[conflicts available for resolution](#conflicts-available-for-resolution) for +more information on when this is available.) If this is an option, you will see +a **resolve these conflicts** link in the merge request widget: + + + +Clicking this will show a list of files with conflicts, with conflict sections +highlighted: + + + +Once all conflicts have been marked as using 'ours' or 'theirs', the conflict +can be resolved. This will perform a merge of the target branch of the merge +request into the source branch, resolving the conflicts using the options +chosen. If the source branch is `feature` and the target branch is `master`, +this is similar to performing `git checkout feature; git merge master` locally. + +## Conflicts available for resolution + +GitLab allows resolving conflicts in a file where all of the below are true: + +- The file is text, not binary +- The file is in a UTF-8 compatible encoding +- The file does not already contain conflict markers +- The file, with conflict markers added, is not over 200 KB in size +- The file exists under the same path in both branches + +If any file with conflicts in that merge request does not meet all of these +criteria, the conflicts for that merge request cannot be resolved in the UI. + +Additionally, GitLab does not detect conflicts in renames away from a path. For +example, this will not create a conflict: on branch `a`, doing `git mv file1 +file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be +present in the branch after the merge request is merged. + +[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479 diff --git a/doc/user/project/merge_requests/revert_changes.md b/doc/user/project/merge_requests/revert_changes.md new file mode 100644 index 00000000000..5ead9f4177f --- /dev/null +++ b/doc/user/project/merge_requests/revert_changes.md @@ -0,0 +1,64 @@ +# Reverting changes + +> [Introduced][ce-1990] in GitLab 8.5. + +--- + +GitLab implements Git's powerful feature to [revert any commit][git-revert] +with introducing a **Revert** button in Merge Requests and commit details. + +## Reverting a Merge Request + +_**Note:** The **Revert** button will only be available for Merge Requests +created since GitLab 8.5. However, you can still revert a Merge Request +by reverting the merge commit from the list of Commits page._ + +After the Merge Request has been merged, a **Revert** button will be available +to revert the changes introduced by that Merge Request: + + + +--- + +You can revert the changes directly into the selected branch or you can opt to +create a new Merge Request with the revert changes: + + + +--- + +After the Merge Request has been reverted, the **Revert** button will not be +available anymore. + +## Reverting a Commit + +You can revert a Commit from the Commit details page: + + + +--- + +Similar to reverting a Merge Request, you can opt to revert the changes +directly into the target branch or create a new Merge Request to revert the +changes: + + + +--- + +After the Commit has been reverted, the **Revert** button will not be available +anymore. + +Please note that when reverting merge commits, the mainline will always be the +first parent. If you want to use a different mainline then you need to do that +from the command line. + +Here is a quick example to revert a merge commit using the second parent as the +mainline: + +```bash +git revert -m 2 7a39eb0 +``` + +[ce-1990]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1990 "Revert button Merge Request" +[git-revert]: https://git-scm.com/docs/git-revert "Git revert documentation" diff --git a/doc/user/project/merge_requests/versions.md b/doc/user/project/merge_requests/versions.md new file mode 100644 index 00000000000..77eab7ba5e3 --- /dev/null +++ b/doc/user/project/merge_requests/versions.md @@ -0,0 +1,42 @@ +# Merge requests versions + +> Will be [introduced][ce-5467] in GitLab 8.12. + +Every time you push to a branch that is tied to a merge request, a new version +of merge request diff is created. When you visit a merge request that contains +more than one pushes, you can select and compare the versions of those merge +request diffs. + + + +--- + +By default, the latest version of changes is shown. However, you +can select an older one from version dropdown. + + + +--- + +You can also compare the merge request version with an older one to see what has +changed since then. + + + +--- + +Every time you push new changes to the branch, a link to compare the last +changes appears as a system note. + + + +--- + +>**Notes:** +- Comments are disabled while viewing outdated merge versions or comparing to + versions other than base. +- Merge request versions are based on push not on commit. So, if you pushed 5 + commits in a single push, it will be a single option in the dropdown. If you + pushed 5 times, that will count for 5 options. + +[ce-5467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5467 diff --git a/doc/user/project/merge_requests/work_in_progress_merge_requests.md b/doc/user/project/merge_requests/work_in_progress_merge_requests.md new file mode 100644 index 00000000000..546c8bdc5e5 --- /dev/null +++ b/doc/user/project/merge_requests/work_in_progress_merge_requests.md @@ -0,0 +1,17 @@ +# "Work In Progress" Merge Requests + +To prevent merge requests from accidentally being accepted before they're +completely ready, GitLab blocks the "Accept" button for merge requests that +have been marked a **Work In Progress**. + + + +To mark a merge request a Work In Progress, simply start its title with `[WIP]` +or `WIP:`. + + + +To allow a Work In Progress merge request to be accepted again when it's ready, +simply remove the `WIP` prefix. + + diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md new file mode 100644 index 00000000000..8827b501901 --- /dev/null +++ b/doc/user/project/new_ci_build_permissions_model.md @@ -0,0 +1,311 @@ +# New CI build permissions model + +> Introduced in GitLab 8.12. + +GitLab 8.12 has a completely redesigned [build permissions] system. You can find +all discussion and all our concerns when choosing the current approach in issue +[#18994](https://gitlab.com/gitlab-org/gitlab-ce/issues/18994). + +--- + +Builds permissions should be tightly integrated with the permissions of a user +who is triggering a build. + +The reasons to do it like that are: + +- We already have a permissions system in place: group and project membership + of users. +- We already fully know who is triggering a build (using `git push`, using the + web UI, executing triggers). +- We already know what user is allowed to do. +- We use the user permissions for builds that are triggered by the user. +- It opens a lot of possibilities to further enforce user permissions, like + allowing only specific users to access runners or use secure variables and + environments. +- It is simple and convenient that your build can access everything that you + as a user have access to. +- Short living unique tokens are now used, granting access for time of the build + and maximizing security. + +With the new behavior, any build that is triggered by the user, is also marked +with their permissions. When a user does a `git push` or changes files through +the web UI, a new pipeline will be usually created. This pipeline will be marked +as created be the pusher (local push or via the UI) and any build created in this +pipeline will have the permissions of the pusher. + +This allows us to make it really easy to evaluate the access for all projects +that have Git submodules or are using container images that the pusher would +have access too. **The permission is granted only for time that build is running. +The access is revoked after the build is finished.** + +## Types of users + +It is important to note that we have a few types of users: + +- **Administrators**: CI builds created by Administrators will not have access + to all GitLab projects, but only to projects and container images of projects + that the administrator is a member of.That means that if a project is either + public or internal users have access anyway, but if a project is private, the + Administrator will have to be a member of it in order to have access to it + via another project's build. + +- **External users**: CI builds created by [external users][ext] will have + access only to projects to which user has at least reporter access. This + rules out accessing all internal projects by default, + +This allows us to make the CI and permission system more trustworthy. +Let's consider the following scenario: + +1. You are an employee of a company. Your company has a number of internal tools + hosted in private repositories and you have multiple CI builds that make use + of these repositories. + +2. You invite a new [external user][ext]. CI builds created by that user do not + have access to internal repositories, because the user also doesn't have the + access from within GitLab. You as an employee have to grant explicit access + for this user. This allows us to prevent from accidental data leakage. + +## Build token + +A unique build token is generated for each build and it allows the user to +access all projects that would be normally accessible to the user creating that +build. + +We try to make sure that this token doesn't leak by: + +1. Securing all API endpoints to not expose the build token. +1. Masking the build token from build logs. +1. Allowing to use the build token **only** when build is running. + +However, this brings a question about the Runners security. To make sure that +this token doesn't leak, you should also make sure that you configure +your Runners in the most possible secure way, by avoiding the following: + +1. Any usage of Docker's `privileged` mode is risky if the machines are re-used. +1. Using the `shell` executor since builds run on the same machine. + +By using an insecure GitLab Runner configuration, you allow the rogue developers +to steal the tokens of other builds. + +## Build triggers + +[Build triggers][triggers] do not support the new permission model. +They continue to use the old authentication mechanism where the CI build +can access only its own sources. We plan to remove that limitation in one of +the upcoming releases. + +## Before GitLab 8.12 + +In versions before GitLab 8.12, all CI builds would use the CI Runner's token +to checkout project sources. + +The project's Runner's token was a token that you could find under the +project's **Settings > CI/CD Pipelines** and was limited to access only that +project. +It could be used for registering new specific Runners assigned to the project +and to checkout project sources. +It could also be used with the GitLab Container Registry for that project, +allowing pulling and pushing Docker images from within the CI build. + +--- + +GitLab would create a special checkout URL like: + +``` +https://gitlab-ci-token:<project-runners-token>/gitlab.com/gitlab-org/gitlab-ce.git +``` + +And then the users could also use it in their CI builds all Docker related +commands to interact with GitLab Container Registry. For example: + +``` +docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com +``` + +Using single token had multiple security implications: + +- The token would be readable to anyone who had developer access to a project + that could run CI builds, allowing the developer to register any specific + Runner for that project. +- The token would allow to access only the project's sources, forbidding from + accessing any other projects. +- The token was not expiring and was multi-purpose: used for checking out sources, + for registering specific runners and for accessing a project's container + registry with read-write permissions. + +All the above led to a new permission model for builds that was introduced +with GitLab 8.12. + +## Making use of the new CI build permissions model + +With the new build permissions model, there is now an easy way to access all +dependent source code in a project. That way, we can: + +1. Access a project's Git submodules +1. Access private container images +1. Access project's and submodule LFS objects + +Below you can see the prerequisites needed to make use of the new permissions +model and how that works with Git submodules and private Docker images hosted on +the container registry. + +### Prerequisites to use the new permissions model + +With the new permissions model in place, there may be times that your build will +fail. This is most likely because your project tries to access other project's +sources, and you don't have the appropriate permissions. In the build log look +for information about 403 or forbidden access messages. + +In short here's what you need to do should you encounter any issues. + +As an administrator: + +- **500 errors**: You will need to update [GitLab Workhorse][workhorse] to at + least 0.8.2. This is done automatically for Omnibus installations, you need to + [check manually][update-docs] for installations from source. +- **500 errors**: Check if you have another web proxy sitting in front of NGINX (HAProxy, + Apache, etc.). It might be a good idea to let GitLab use the internal NGINX + web server and not disable it completely. See [this comment][comment] for an + example. +- **403 errors**: You need to make sure that your installation has [HTTP(S) + cloning enabled][https]. HTTP(S) support is now a **requirement** by GitLab CI + to clone all sources. + +As a user: + +- Make sure you are a member of the group or project you're trying to have + access to. As an Administrator, you can verify that by impersonating the user + and retry the failing build in order to verify that everything is correct. + +### Git submodules + +> +It often happens that while working on one project, you need to use another +project from within it; perhaps it’s a library that a third party developed or +you’re developing a project separately and are using it in multiple parent +projects. +A common issue arises in these scenarios: you want to be able to treat the two +projects as separate yet still be able to use one from within the other. +> +_Excerpt from the [Git website][git-scm] about submodules._ + +If dealing with submodules, your project will probably have a file named +`.gitmodules`. And this is how it usually looks like: + +``` +[submodule "tools"] + path = tools + url = git@gitlab.com/group/tools.git +``` + +> **Note:** +If you are **not** using GitLab 8.12 or higher, you would need to work your way +around this issue in order to access the sources of `gitlab.com/group/tools` +(e.g., use [SSH keys](../ssh_keys/README.md)). +> +With GitLab 8.12 onward, your permissions are used to evaluate what a CI build +can access. More information about how this system works can be found in the +[Build permissions model](../../user/permissions.md#builds-permissions). + +To make use of the new changes, you have to update your `.gitmodules` file to +use a relative URL. + +Let's consider the following example: + +1. Your project is located at `https://gitlab.com/secret-group/my-project`. +1. To checkout your sources you usually use an SSH address like + `git@gitlab.com:secret-group/my-project.git`. +1. Your project depends on `https://gitlab.com/group/tools`. +1. You have the `.gitmodules` file with above content. + +Since Git allows the usage of relative URLs for your `.gitmodules` configuration, +this easily allows you to use HTTP for cloning all your CI builds and SSH +for all your local checkouts. + +For example, if you change the `url` of your `tools` dependency, from +`git@gitlab.com/group/tools.git` to `../../group/tools.git`, this will instruct +Git to automatically deduce the URL that should be used when cloning sources. +Whether you use HTTP or SSH, Git will use that same channel and it will allow +to make all your CI builds use HTTPS (because GitLab CI uses HTTPS for cloning +your sources), and all your local clones will continue using SSH. + +Given the above explanation, your `.gitmodules` file should eventually look +like this: + +``` +[submodule "tools"] + path = tools + url = ../../group/tools.git +``` + +However, you have to explicitly tell GitLab CI to clone your submodules as this +is not done automatically. You can achieve that by adding a `before_script` +section to your `.gitlab-ci.yml`: + +``` +before_script: + - git submodule update --init --recursive + +test: + script: + - run-my-tests +``` + +This will make GitLab CI initialize (fetch) and update (checkout) all your +submodules recursively. + +In case your environment or your Docker image doesn't have Git installed, +you have to either ask your Administrator or install the missing dependency +yourself: + +``` +# Debian / Ubuntu +before_script: + - apt-get update -y + - apt-get install -y git-core + - git submodule update --init --recursive + +# CentOS / RedHat +before_script: + - yum install git + - git submodule update --init --recursive + +# Alpine +before_script: + - apk add -U git + - git submodule update --init --recursive +``` + +### Container Registry + +With the update permission model we also extended the support for accessing +Container Registries for private projects. + +> **Note:** +As GitLab Runner 1.6 doesn't yet incorporate the introduced changes for +permissions, this makes the `image:` directive to not work with private projects +automatically. The manual configuration by an Administrator is required to use +private images. We plan to remove that limitation in one of the upcoming releases. + +Your builds can access all container images that you would normally have access +to. The only implication is that you can push to the Container Registry of the +project for which the build is triggered. + +This is how an example usage can look like: + +``` +test: + script: + - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY + - docker pull $CI_REGISTRY/group/other-project:latest + - docker run $CI_REGISTRY/group/other-project:latest +``` + +[build permissions]: ../permissions.md#builds-permissions +[comment]: https://gitlab.com/gitlab-org/gitlab-ce/issues/22484#note_16648302 +[ext]: ../permissions.md#external-users +[git-scm]: https://git-scm.com/book/en/v2/Git-Tools-Submodules +[https]: ../admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols +[triggers]: ../../ci/triggers/README.md +[update-docs]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/doc/update +[workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md index 6a8170b5ecb..f7a686d2ccf 100644 --- a/doc/user/project/protected_branches.md +++ b/doc/user/project/protected_branches.md @@ -5,6 +5,8 @@ idea of having read or write permission to the repository and branches. To prevent people from messing with history or pushing code without review, we've created protected branches. +## Overview + By default, a protected branch does four simple things: - it prevents its creation, if not already created, from everybody except users @@ -15,6 +17,11 @@ By default, a protected branch does four simple things: See the [Changelog](#changelog) section for changes over time. +> +>Additional functionality for GitLab Enterprise Edition: +> +>- Restrict push and merge access to [certain users][ee-restrict] + ## Configuring protected branches To protect a branch, you need to have at least Master permission level. Note @@ -28,27 +35,45 @@ that the `master` branch is protected by default. 1. From the **Branch** dropdown menu, select the branch you want to protect and click **Protect**. In the screenshot below, we chose the `develop` branch. -  +  -1. Once done, the protected branch will appear in the "Already protected" list. +1. Once done, the protected branch will appear in the "Protected branches" list.  +## Using the Allowed to merge and Allowed to push settings + +> [Introduced][ce-5081] in GitLab 8.11. + +Since GitLab 8.11, we added another layer of branch protection which provides +more granular management of protected branches. The "Developers can push" +option was replaced by an "Allowed to push" setting which can be set to +allow/prohibit Masters and/or Developers to push to a protected branch. + +Using the "Allowed to push" and "Allowed to merge" settings, you can control +the actions that different roles can perform with the protected branch. +For example, you could set "Allowed to push" to "No one", and "Allowed to merge" +to "Developers + Masters", to require _everyone_ to submit a merge request for +changes going into the protected branch. This is compatible with workflows like +the [GitLab workflow](../../workflow/gitlab_flow.md). + +However, there are workflows where that is not needed, and only protecting from +force pushes and branch removal is useful. For those workflows, you can allow +everyone with write access to push to a protected branch by setting +"Allowed to push" to "Developers + Masters". + +You can set the "Allowed to push" and "Allowed to merge" options while creating +a protected branch or afterwards by selecting the option you want from the +dropdown list in the "Already protected" area. -Since GitLab 8.10, we added another layer of branch protection which provides -more granular management of protected branches. You can now choose the option -"Developers can merge" so that Developer users can merge a merge request but -not directly push. In that case, your branches are protected from direct pushes, -yet Developers don't need elevated permissions or wait for someone with a higher -permission level to press merge. + -You can set this option while creating the protected branch or after its -creation. +If you don't choose any of those options while creating a protected branch, +they are set to "Masters" by default. ## Wildcard protected branches ->**Note:** -This feature was [introduced][ce-4665] in GitLab 8.10. +> [Introduced][ce-4665] in GitLab 8.10. You can specify a wildcard protected branch, which will protect all branches matching the wildcard. For example: @@ -67,40 +92,25 @@ Two different wildcards can potentially match the same branch. For example, In that case, if _any_ of these protected branches have a setting like "Allowed to push", then `production-stable` will also inherit this setting. -If you click on a protected branch's name that is created using a wildcard, -you will be presented with a list of all matching branches: +If you click on a protected branch's name, you will be presented with a list of +all matching branches:  -## Restrict the creation of protected branches - -Creating a protected branch or a list of protected branches using the wildcard -feature, not only you are restricting pushes to those branches, but also their -creation if not already created. - -## Error messages when pushing to a protected branch - -A user with insufficient permissions will be presented with an error when -creating or pushing to a branch that's prohibited, either through GitLab's UI: - - - -or using Git from their terminal: +## Changelog -```bash -remote: GitLab: You are not allowed to push code to protected branches on this project. -To https://gitlab.example.com/thedude/bowling.git - ! [remote rejected] staging-stable -> staging-stable (pre-receive hook declined) -error: failed to push some refs to 'https://gitlab.example.com/thedude/bowling.git' -``` +**8.11** -## Changelog +- Allow creating protected branches that can't be pushed to [gitlab-org/gitlab-ce!5081][ce-5081] -**8.10.0** +**8.10** -- Allow specifying protected branches using wildcards [gitlab-org/gitlab-ce!5081][ce-4665] +- Allow developers to merge into a protected branch without having push access [gitlab-org/gitlab-ce!4892][ce-4892] +- Allow specifying protected branches using wildcards [gitlab-org/gitlab-ce!4665][ce-4665] --- [ce-4665]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4665 "Allow specifying protected branches using wildcards" +[ce-4892]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4892 "Allow developers to merge into a protected branch without having push access" [ce-5081]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5081 "Allow creating protected branches that can't be pushed to" +[ee-restrict]: http://docs.gitlab.com/ee/user/project/protected_branches.html#restricting-push-and-merge-access-to-certain-users diff --git a/doc/workflow/img/web_editor_new_branch_dropdown.png b/doc/user/project/repository/img/web_editor_new_branch_dropdown.png Binary files differindex a8e635d2faf..a8e635d2faf 100644 --- a/doc/workflow/img/web_editor_new_branch_dropdown.png +++ b/doc/user/project/repository/img/web_editor_new_branch_dropdown.png diff --git a/doc/user/project/repository/img/web_editor_new_branch_from_issue.png b/doc/user/project/repository/img/web_editor_new_branch_from_issue.png Binary files differnew file mode 100644 index 00000000000..b0a63ddf0ab --- /dev/null +++ b/doc/user/project/repository/img/web_editor_new_branch_from_issue.png diff --git a/doc/workflow/img/web_editor_new_branch_page.png b/doc/user/project/repository/img/web_editor_new_branch_page.png Binary files differindex 7f36b7faf63..7f36b7faf63 100644 --- a/doc/workflow/img/web_editor_new_branch_page.png +++ b/doc/user/project/repository/img/web_editor_new_branch_page.png diff --git a/doc/workflow/img/web_editor_new_directory_dialog.png b/doc/user/project/repository/img/web_editor_new_directory_dialog.png Binary files differindex d16e3c67116..d16e3c67116 100644 --- a/doc/workflow/img/web_editor_new_directory_dialog.png +++ b/doc/user/project/repository/img/web_editor_new_directory_dialog.png diff --git a/doc/workflow/img/web_editor_new_directory_dropdown.png b/doc/user/project/repository/img/web_editor_new_directory_dropdown.png Binary files differindex c8d77b16ee8..c8d77b16ee8 100644 --- a/doc/workflow/img/web_editor_new_directory_dropdown.png +++ b/doc/user/project/repository/img/web_editor_new_directory_dropdown.png diff --git a/doc/workflow/img/web_editor_new_file_dropdown.png b/doc/user/project/repository/img/web_editor_new_file_dropdown.png Binary files differindex 3fcb91c9b93..3fcb91c9b93 100644 --- a/doc/workflow/img/web_editor_new_file_dropdown.png +++ b/doc/user/project/repository/img/web_editor_new_file_dropdown.png diff --git a/doc/workflow/img/web_editor_new_file_editor.png b/doc/user/project/repository/img/web_editor_new_file_editor.png Binary files differindex 21c340b9288..21c340b9288 100644 --- a/doc/workflow/img/web_editor_new_file_editor.png +++ b/doc/user/project/repository/img/web_editor_new_file_editor.png diff --git a/doc/workflow/img/web_editor_new_push_widget.png b/doc/user/project/repository/img/web_editor_new_push_widget.png Binary files differindex c7738a4c930..c7738a4c930 100644 --- a/doc/workflow/img/web_editor_new_push_widget.png +++ b/doc/user/project/repository/img/web_editor_new_push_widget.png diff --git a/doc/workflow/img/web_editor_new_tag_dropdown.png b/doc/user/project/repository/img/web_editor_new_tag_dropdown.png Binary files differindex ac7415009b3..ac7415009b3 100644 --- a/doc/workflow/img/web_editor_new_tag_dropdown.png +++ b/doc/user/project/repository/img/web_editor_new_tag_dropdown.png diff --git a/doc/workflow/img/web_editor_new_tag_page.png b/doc/user/project/repository/img/web_editor_new_tag_page.png Binary files differindex 231e1a13fc0..231e1a13fc0 100644 --- a/doc/workflow/img/web_editor_new_tag_page.png +++ b/doc/user/project/repository/img/web_editor_new_tag_page.png diff --git a/doc/workflow/img/web_editor_start_new_merge_request.png b/doc/user/project/repository/img/web_editor_start_new_merge_request.png Binary files differindex 2755501dfd1..2755501dfd1 100644 --- a/doc/workflow/img/web_editor_start_new_merge_request.png +++ b/doc/user/project/repository/img/web_editor_start_new_merge_request.png diff --git a/doc/user/project/repository/img/web_editor_template_dropdown_buttons.png b/doc/user/project/repository/img/web_editor_template_dropdown_buttons.png Binary files differnew file mode 100644 index 00000000000..4efc51cc423 --- /dev/null +++ b/doc/user/project/repository/img/web_editor_template_dropdown_buttons.png diff --git a/doc/user/project/repository/img/web_editor_template_dropdown_first_file.png b/doc/user/project/repository/img/web_editor_template_dropdown_first_file.png Binary files differnew file mode 100644 index 00000000000..67190c58823 --- /dev/null +++ b/doc/user/project/repository/img/web_editor_template_dropdown_first_file.png diff --git a/doc/user/project/repository/img/web_editor_template_dropdown_mit_license.png b/doc/user/project/repository/img/web_editor_template_dropdown_mit_license.png Binary files differnew file mode 100644 index 00000000000..47719113805 --- /dev/null +++ b/doc/user/project/repository/img/web_editor_template_dropdown_mit_license.png diff --git a/doc/workflow/img/web_editor_upload_file_dialog.png b/doc/user/project/repository/img/web_editor_upload_file_dialog.png Binary files differindex 9d6d8250bbe..9d6d8250bbe 100644 --- a/doc/workflow/img/web_editor_upload_file_dialog.png +++ b/doc/user/project/repository/img/web_editor_upload_file_dialog.png diff --git a/doc/workflow/img/web_editor_upload_file_dropdown.png b/doc/user/project/repository/img/web_editor_upload_file_dropdown.png Binary files differindex 6b5205b05ec..6b5205b05ec 100644 --- a/doc/workflow/img/web_editor_upload_file_dropdown.png +++ b/doc/user/project/repository/img/web_editor_upload_file_dropdown.png diff --git a/doc/user/project/repository/web_editor.md b/doc/user/project/repository/web_editor.md new file mode 100644 index 00000000000..675e89e4247 --- /dev/null +++ b/doc/user/project/repository/web_editor.md @@ -0,0 +1,175 @@ +# GitLab Web Editor + +Sometimes it's easier to make quick changes directly from the GitLab interface +than to clone the project and use the Git command line tool. In this feature +highlight we look at how you can create a new file, directory, branch or +tag from the file browser. All of these actions are available from a single +dropdown menu. + +## Create a file + +From a project's files page, click the '+' button to the right of the branch selector. +Choose **New file** from the dropdown. + + + +--- + +Enter a file name in the **File name** box. Then, add file content in the editor +area. Add a descriptive commit message and choose a branch. The branch field +will default to the branch you were viewing in the file browser. If you enter +a new branch name, a checkbox will appear allowing you to start a new merge +request after you commit the changes. + +When you are satisfied with your new file, click **Commit Changes** at the bottom. + + + +### Template dropdowns + +When starting a new project, there are some common files which the new project +might need too. Therefore a message will be displayed by GitLab to make this +easy for you. + + + +When clicking on either `LICENSE` or `.gitignore`, a dropdown will be displayed +to provide you with a template which might be suitable for your project. + + + +The license, changelog, contribution guide, or `.gitlab-ci.yml` file could also +be added through a button on the project page. In the example below the license +has already been created, which creates a link to the license itself. + + + +>**Note:** +The **Set up CI** button will not appear on an empty repository. You have to at +least add a file in order for the button to show up. + +## Upload a file + +The ability to create a file is great when the content is text. However, this +doesn't work well for binary data such as images, PDFs or other file types. In +this case you need to upload a file. + +From a project's files page, click the '+' button to the right of the branch +selector. Choose **Upload file** from the dropdown. + + + +--- + +Once the upload dialog pops up there are two ways to upload your file. Either +drag and drop a file on the pop up or use the **click to upload** link. A file +preview will appear once you have selected a file to upload. + +Enter a commit message, choose a branch, and click **Upload file** when you are +ready. + + + +## Create a directory + +To keep files in the repository organized it is often helpful to create a new +directory. + +From a project's files page, click the '+' button to the right of the branch selector. +Choose **New directory** from the dropdown. + + + +--- + +In the new directory dialog enter a directory name, a commit message and choose +the target branch. Click **Create directory** to finish. + + + +## Create a new branch + +There are multiple ways to create a branch from GitLab's web interface. + +### Create a new branch from an issue + +> [Introduced][ce-2808] in GitLab 8.6. + +In case your development workflow dictates to have an issue for every merge +request, you can quickly create a branch right on the issue page which will be +tied with the issue itself. You can see a **New branch** button after the issue +description, unless there is already a branch with the same name or a referenced +merge request. + + + +Once you click it, a new branch will be created that diverges from the default +branch of your project, by default `master`. The branch name will be based on +the title of the issue and as suffix it will have its ID. Thus, the example +screenshot above will yield a branch named +`2-et-cum-et-sed-expedita-repellat-consequatur-ut-assumenda-numquam-rerum`. + +After the branch is created, you can edit files in the repository to fix +the issue. When a merge request is created based on the newly created branch, +the description field will automatically display the [issue closing pattern] +`Closes #ID`, where `ID` the ID of the issue. This will close the issue once the +merge request is merged. + +### Create a new branch from a project's dashboard + +If you want to make changes to several files before creating a new merge +request, you can create a new branch up front. From a project's files page, +choose **New branch** from the dropdown. + + + +--- + +Enter a new **Branch name**. Optionally, change the **Create from** field +to choose which branch, tag or commit SHA this new branch will originate from. +This field will autocomplete if you start typing an existing branch or tag. +Click **Create branch** and you will be returned to the file browser on this new +branch. + + + +--- + +You can now make changes to any files, as needed. When you're ready to merge +the changes back to master you can use the widget at the top of the screen. +This widget only appears for a period of time after you create the branch or +modify files. + + + +## Create a new tag + +Tags are useful for marking major milestones such as production releases, +release candidates, and more. You can create a tag from a branch or a commit +SHA. From a project's files page, choose **New tag** from the dropdown. + + + +--- + +Give the tag a name such as `v1.0.0`. Choose the branch or SHA from which you +would like to create this new tag. You can optionally add a message and +release notes. The release notes section supports markdown format and you can +also upload an attachment. Click **Create tag** and you will be taken to the tag +list page. + + + +## Tips + +When creating or uploading a new file, or creating a new directory, you can +trigger a new merge request rather than committing directly to master. Enter +a new branch name in the **Target branch** field. You will notice a checkbox +appear that is labeled **Start a new merge request with these changes**. After +you commit the changes you will be taken to a new merge request form. + + + + +[ce-2808]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2808 +[issue closing pattern]: ../user/project/issues/automatic_issue_closing.md diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 38e9786123d..65ed9fae4ec 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -1,22 +1,37 @@ # Project import/export >**Notes:** - - This feature was [introduced][ce-3050] in GitLab 8.9 - - Importing will not be possible if the import instance version is lower - than that of the exporter. - - For existing installations, the project import option has to be enabled in - application settings (`/admin/application_settings`) under 'Import sources'. - Ask your administrator if you don't see the **GitLab export** button when - creating a new project. - - You can find some useful raketasks if you are an administrator in the - [import_export](../../../administration/raketasks/project_import_export.md) - raketask. - - The exports are stored in a temporary [shared directory][tmp] and are deleted - every 24 hours by a specific worker. +> +> - [Introduced][ce-3050] in GitLab 8.9. +> - Importing will not be possible if the import instance version differs from +> that of the exporter. +> - For existing installations, the project import option has to be enabled in +> application settings (`/admin/application_settings`) under 'Import sources'. +> Ask your administrator if you don't see the **GitLab export** button when +> creating a new project. +> - You can find some useful raketasks if you are an administrator in the +> [import_export](../../../administration/raketasks/project_import_export.md) +> raketask. +> - The exports are stored in a temporary [shared directory][tmp] and are deleted +> every 24 hours by a specific worker. Existing projects running on any GitLab instance or GitLab.com can be exported with all their related data and be moved into a new GitLab instance. +## Version history + +| GitLab version | Import/Export version | +| -------- | -------- | +| 8.12.0 to current | 0.1.4 | +| 8.10.3 | 0.1.3 | +| 8.10.0 | 0.1.2 | +| 8.9.5 | 0.1.1 | +| 8.9.0 | 0.1.0 | + + > The table reflects what GitLab version we updated the Import/Export version at. + > For instance, 8.10.3 and 8.11 will have the same Import/Export version (0.1.3) + > and the exports between them will be compatible. + ## Exported contents The following items will be exported: diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md new file mode 100644 index 00000000000..5f6a6c6503e --- /dev/null +++ b/doc/user/project/slash_commands.md @@ -0,0 +1,31 @@ +# GitLab slash commands + +Slash commands are textual shortcuts for common actions on issues or merge +requests that are usually done by clicking buttons or dropdowns in GitLab's UI. +You can enter these commands while creating a new issue or merge request, and +in comments. Each command should be on a separate line in order to be properly +detected and executed. The commands are removed from the issue, merge request or +comment body before it is saved and will not be visible to anyone else. + +Below is a list of all of the available commands and descriptions about what they +do. + +| Command | Action | +|:---------------------------|:-------------| +| `/close` | Close the issue or merge request | +| `/reopen` | Reopen the issue or merge request | +| `/title <New title>` | Change title | +| `/assign @username` | Assign | +| `/unassign` | Remove assignee | +| `/milestone %milestone` | Set milestone | +| `/remove_milestone` | Remove milestone | +| `/label ~foo ~"bar baz"` | Add label(s) | +| `/unlabel ~foo ~"bar baz"` | Remove all or specific label(s) | +| `/relabel ~foo ~"bar baz"` | Replace all label(s) | +| `/todo` | Add a todo | +| `/done` | Mark todo as done | +| `/subscribe` | Subscribe | +| `/unsubscribe` | Unsubscribe | +| <code>/due <in 2 days | this Friday | December 31st></code> | Set due date | +| `/remove_due_date` | Remove due date | +| `/wip` | Toggle the Work In Progress status | diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md index 8559b67af04..33c1a79d59c 100644 --- a/doc/web_hooks/web_hooks.md +++ b/doc/web_hooks/web_hooks.md @@ -26,6 +26,10 @@ GitLab webhooks keep in mind the following things: you are writing a low-level hook this is important to remember. - GitLab ignores the HTTP status code returned by your endpoint. +## Secret Token + +If you specify a secret token, it will be sent with the hook request in the `X-Gitlab-Token` HTTP header. Your webhook endpoint can check that to verify that the request is legitimate. + ## SSL Verification By default, the SSL certificate of the webhook endpoint is verified based on @@ -750,6 +754,174 @@ X-Gitlab-Event: Wiki Page Hook } ``` +## Pipeline events + +Triggered on status change of Pipeline. + +**Request Header**: + +``` +X-Gitlab-Event: Pipeline Hook +``` + +**Request Body**: + +```json +{ + "object_kind": "pipeline", + "object_attributes":{ + "id": 31, + "ref": "master", + "tag": false, + "sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "status": "success", + "stages":[ + "build", + "test", + "deploy" + ], + "created_at": "2016-08-12 15:23:28 UTC", + "finished_at": "2016-08-12 15:26:29 UTC", + "duration": 63 + }, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "project":{ + "name": "Gitlab Test", + "description": "Atque in sunt eos similique dolores voluptatem.", + "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test", + "avatar_url": null, + "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git", + "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git", + "namespace": "Gitlab Org", + "visibility_level": 20, + "path_with_namespace": "gitlab-org/gitlab-test", + "default_branch": "master" + }, + "commit":{ + "id": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "message": "test\n", + "timestamp": "2016-08-12T17:23:21+02:00", + "url": "http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "author":{ + "name": "User", + "email": "user@gitlab.com" + } + }, + "builds":[ + { + "id": 380, + "stage": "deploy", + "name": "production", + "status": "skipped", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": null, + "finished_at": null, + "when": "manual", + "manual": true, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 377, + "stage": "test", + "name": "test-image", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:26:12 UTC", + "finished_at": null, + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 378, + "stage": "test", + "name": "test-build", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:26:12 UTC", + "finished_at": "2016-08-12 15:26:29 UTC", + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 376, + "stage": "build", + "name": "build-image", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:24:56 UTC", + "finished_at": "2016-08-12 15:25:26 UTC", + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 379, + "stage": "deploy", + "name": "staging", + "status": "created", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": null, + "finished_at": null, + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + } + ] +} +``` + #### Example webhook receiver If you want to see GitLab's webhooks in action for testing purposes you can use diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 49dec613716..2d9bfbc0629 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -1,10 +1,13 @@ # Workflow -- [Authorization for merge requests](authorization_for_merge_requests.md) +- [Automatic issue closing](../user/project/issues/automatic_issue_closing.md) - [Change your time zone](timezone.md) +- [Cycle Analytics](../user/project/cycle_analytics.md) +- [Description templates](../user/project/description_templates.md) - [Feature branch workflow](workflow.md) - [GitLab Flow](gitlab_flow.md) - [Groups](groups.md) +- [Issue Board](../user/project/issue_board.md) - [Keyboard shortcuts](shortcuts.md) - [File finder](file_finder.md) - [Labels](../user/project/labels.md) @@ -13,16 +16,21 @@ - [Project forking workflow](forking_workflow.md) - [Project users](add-user/add-user.md) - [Protected branches](../user/project/protected_branches.md) +- [Slash commands](../user/project/slash_commands.md) - [Sharing a project with a group](share_with_group.md) - [Share projects with other groups](share_projects_with_other_groups.md) -- [Web Editor](web_editor.md) +- [Web Editor](../user/project/repository/web_editor.md) - [Releases](releases.md) - [Milestones](milestones.md) -- [Merge Requests](merge_requests.md) -- [Revert changes](revert_changes.md) -- [Cherry-pick changes](cherry_pick_changes.md) -- ["Work In Progress" Merge Requests](wip_merge_requests.md) -- [Merge When Build Succeeds](merge_when_build_succeeds.md) +- [Merge Requests](../user/project/merge_requests.md) + - [Authorization for merge requests](../user/project/merge_requests/authorization_for_merge_requests.md) + - [Cherry-pick changes](../user/project/merge_requests/cherry_pick_changes.md) + - [Merge when build succeeds](../user/project/merge_requests/merge_when_build_succeeds.md) + - [Resolve discussion comments in merge requests reviews](../user/project/merge_requests/merge_request_discussion_resolution.md) + - [Resolve merge conflicts in the UI](../user/project/merge_requests/resolve_conflicts.md) + - [Revert changes in the UI](../user/project/merge_requests/revert_changes.md) + - [Merge requests versions](../user/project/merge_requests/versions.md) + - ["Work In Progress" merge requests](../user/project/merge_requests/work_in_progress_merge_requests.md) - [Manage large binaries with Git LFS](lfs/manage_large_binaries_with_git_lfs.md) - [Importing from SVN, GitHub, BitBucket, etc](importing/README.md) - [Todos](todos.md) diff --git a/doc/workflow/authorization_for_merge_requests.md b/doc/workflow/authorization_for_merge_requests.md index d1d6d94ec11..7bf80a3ad0d 100644 --- a/doc/workflow/authorization_for_merge_requests.md +++ b/doc/workflow/authorization_for_merge_requests.md @@ -1,40 +1 @@ -# Authorization for Merge requests - -There are two main ways to have a merge request flow with GitLab: working with protected branches in a single repository, or working with forks of an authoritative project. - -## Protected branch flow - -With the protected branch flow everybody works within the same GitLab project. - -The project maintainers get Master access and the regular developers get Developer access. - -The maintainers mark the authoritative branches as 'Protected'. - -The developers push feature branches to the project and create merge requests to have their feature branches reviewed and merged into one of the protected branches. - -Only users with Master access can merge changes into a protected branch. - -### Advantages - -- fewer projects means less clutter -- developers need to consider only one remote repository - -### Disadvantages - -- manual setup of protected branch required for each new project - -## Forking workflow - -With the forking workflow the maintainers get Master access and the regular developers get Reporter access to the authoritative repository, which prohibits them from pushing any changes to it. - -Developers create forks of the authoritative project and push their feature branches to their own forks. - -To get their changes into master they need to create a merge request across forks. - -### Advantages - -- in an appropriately configured GitLab group, new projects automatically get the required access restrictions for regular developers: fewer manual steps to configure authorization for new projects - -### Disadvantages - -- the project need to keep their forks up to date, which requires more advanced Git skills (managing multiple remotes) +This document was moved to [user/project/merge_requests/authorization_for_merge_requests](../user/project/merge_requests/authorization_for_merge_requests.md) diff --git a/doc/workflow/award_emoji.md b/doc/workflow/award_emoji.md index e6f8b792707..1df0698afd0 100644 --- a/doc/workflow/award_emoji.md +++ b/doc/workflow/award_emoji.md @@ -1,7 +1,7 @@ # Award emoji >**Note:** -This feature was [introduced][1825] in GitLab 8.2. +[Introduced][1825] in GitLab 8.2. When you're collaborating online, you get fewer opportunities for high-fives and thumbs-ups. Emoji can be awarded to issues and merge requests, making @@ -16,7 +16,7 @@ award emoji. ## Sort issues and merge requests on vote count >**Note:** -This feature was [introduced][2871] in GitLab 8.5. +[Introduced][2871] in GitLab 8.5. You can quickly sort issues and merge requests by the number of votes they have received. The sort options can be found in the dropdown menu as "Most @@ -45,7 +45,7 @@ downvotes. ## Award emoji for comments >**Note:** -This feature was [introduced][4291] in GitLab 8.9. +[Introduced][4291] in GitLab 8.9. Award emoji can also be applied to individual comments when you want to celebrate an accomplishment or agree with an opinion. diff --git a/doc/workflow/cherry_pick_changes.md b/doc/workflow/cherry_pick_changes.md index 4a499009842..663ffd3f746 100644 --- a/doc/workflow/cherry_pick_changes.md +++ b/doc/workflow/cherry_pick_changes.md @@ -1,53 +1 @@ -# Cherry-pick changes - ->**Note:** -This feature was [introduced][ce-3514] in GitLab 8.7. - ---- - -GitLab implements Git's powerful feature to [cherry-pick any commit][git-cherry-pick] -with introducing a **Cherry-pick** button in Merge Requests and commit details. - -## Cherry-picking a Merge Request - -After the Merge Request has been merged, a **Cherry-pick** button will be available -to cherry-pick the changes introduced by that Merge Request: - - - ---- - -You can cherry-pick the changes directly into the selected branch or you can opt to -create a new Merge Request with the cherry-pick changes: - - - -## Cherry-picking a Commit - -You can cherry-pick a Commit from the Commit details page: - - - ---- - -Similar to cherry-picking a Merge Request, you can opt to cherry-pick the changes -directly into the target branch or create a new Merge Request to cherry-pick the -changes: - - - ---- - -Please note that when cherry-picking merge commits, the mainline will always be the -first parent. If you want to use a different mainline then you need to do that -from the command line. - -Here is a quick example to cherry-pick a merge commit using the second parent as the -mainline: - -```bash -git cherry-pick -m 2 7a39eb0 -``` - -[ce-3514]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3514 "Cherry-pick button Merge Request" -[git-cherry-pick]: https://git-scm.com/docs/git-cherry-pick "Git cherry-pick documentation" +This document was moved to [user/project/merge_requests/cherry_pick_changes](../user/project/merge_requests/cherry_pick_changes.md). diff --git a/doc/workflow/file_finder.md b/doc/workflow/file_finder.md index b69ae663272..8d87b030c83 100644 --- a/doc/workflow/file_finder.md +++ b/doc/workflow/file_finder.md @@ -1,6 +1,6 @@ # File finder -_**Note:** This feature was [introduced][gh-9889] in GitLab 8.4._ +> [Introduced][gh-9889] in GitLab 8.4. --- diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md index 2b2f140f8bf..7c0eb90d540 100644 --- a/doc/workflow/gitlab_flow.md +++ b/doc/workflow/gitlab_flow.md @@ -89,7 +89,7 @@ In this case the master branch is deployed on staging. When someone wants to dep And going live with code happens by merging the pre-production branch into the production branch. This workflow where commits only flow downstream ensures that everything has been tested on all environments. If you need to cherry-pick a commit with a hotfix it is common to develop it on a feature branch and merge it into master with a merge request, do not delete the feature branch. -If master is good to go (it should be if you a practicing [continuous delivery](http://martinfowler.com/bliki/ContinuousDelivery.html)) you then merge it to the other branches. +If master is good to go (it should be if you are practicing [continuous delivery](http://martinfowler.com/bliki/ContinuousDelivery.html)) you then merge it to the other branches. If this is not possible because more manual testing is required you can send merge requests from the feature branch to the downstream branches. An 'extreme' version of environment branches are setting up an environment for each feature branch as done by [Teatro](https://teatro.io/). @@ -115,7 +115,7 @@ In this flow it is not common to have a production branch (or git flow master br Merge or pull requests are created in a git management application and ask an assigned person to merge two branches. Tools such as GitHub and Bitbucket choose the name pull request since the first manual action would be to pull the feature branch. -Tools such as GitLab and Gitorious choose the name merge request since that is the final action that is requested of the assignee. +Tools such as GitLab and others choose the name merge request since that is the final action that is requested of the assignee. In this article we'll refer to them as merge requests. If you work on a feature branch for more than a few hours it is good to share the intermediate result with the rest of the team. diff --git a/doc/workflow/importing/img/import_projects_from_github_importer.png b/doc/workflow/importing/img/import_projects_from_github_importer.png Binary files differindex b6ed8dd692a..eadd33c695f 100644 --- a/doc/workflow/importing/img/import_projects_from_github_importer.png +++ b/doc/workflow/importing/img/import_projects_from_github_importer.png diff --git a/doc/workflow/importing/img/import_projects_from_github_new_project_page.png b/doc/workflow/importing/img/import_projects_from_github_new_project_page.png Binary files differindex c8f35a50f48..6e91c430a33 100644 --- a/doc/workflow/importing/img/import_projects_from_github_new_project_page.png +++ b/doc/workflow/importing/img/import_projects_from_github_new_project_page.png diff --git a/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png b/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png Binary files differnew file mode 100644 index 00000000000..c11863ab10c --- /dev/null +++ b/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png diff --git a/doc/workflow/importing/import_projects_from_github.md b/doc/workflow/importing/import_projects_from_github.md index a2b2a4b88f9..c36dfdb78ec 100644 --- a/doc/workflow/importing/import_projects_from_github.md +++ b/doc/workflow/importing/import_projects_from_github.md @@ -1,56 +1,118 @@ # Import your project from GitHub to GitLab
+Import your projects from GitHub to GitLab with minimal effort.
+
+## Overview
+
>**Note:**
-In order to enable the GitHub import setting, you may also want to
-enable the [GitHub integration][gh-import] in your GitLab instance. This
-configuration is optional, you will be able import your GitHub repositories
-with a Personal Access Token.
+If you are an administrator you can enable the [GitHub integration][gh-import]
+in your GitLab instance sitewide. This configuration is optional, users will be
+able import their GitHub repositories with a [personal access token][gh-token].
+
+- At its current state, GitHub importer can import:
+ - the repository description (GitLab 7.7+)
+ - the Git repository data (GitLab 7.7+)
+ - the issues (GitLab 7.7+)
+ - the pull requests (GitLab 8.4+)
+ - the wiki pages (GitLab 8.4+)
+ - the milestones (GitLab 8.7+)
+ - the labels (GitLab 8.7+)
+ - the release note descriptions (GitLab 8.12+)
+- References to pull requests and issues are preserved (GitLab 8.7+)
+- Repository public access is retained. If a repository is private in GitHub
+ it will be created as private in GitLab as well.
+
+## How it works
+
+When issues/pull requests are being imported, the GitHub importer tries to find
+the GitHub author/assignee in GitLab's database using the GitHub ID. For this
+to work, the GitHub author/assignee should have signed in beforehand in GitLab
+and [**associated their GitHub account**][social sign-in]. If the user is not
+found in GitLab's database, the project creator (most of the times the current
+user that started the import process) is set as the author, but a reference on
+the issue about the original GitHub author is kept.
+
+The importer will create any new namespaces (groups) if they don't exist or in
+the case the namespace is taken, the repository will be imported under the user's
+namespace that started the import process.
+
+## Importing your GitHub repositories
+
+The importer page is visible when you create a new project.
+
+
-At its current state, GitHub importer can import:
+Click on the **GitHub** link and the import authorization process will start.
+There are two ways to authorize access to your GitHub repositories:
-- the repository description (introduced in GitLab 7.7)
-- the git repository data (introduced in GitLab 7.7)
-- the issues (introduced in GitLab 7.7)
-- the pull requests (introduced in GitLab 8.4)
-- the wiki pages (introduced in GitLab 8.4)
-- the milestones (introduced in GitLab 8.7)
-- the labels (introduced in GitLab 8.7)
+1. [Using the GitHub integration][gh-integration] (if it's enabled by your
+ GitLab administrator). This is the preferred way as it's possible to
+ preserve the GitHub authors/assignees. Read more in the [How it works](#how-it-works)
+ section.
+1. [Using a personal access token][gh-token] provided by GitHub.
-With GitLab 8.7+, references to pull requests and issues are preserved.
+
-It is not yet possible to import your cross-repository pull requests (those from
-forks). We are working on improving this in the near future.
+### Authorize access to your repositories using the GitHub integration
-The importer page is visible when you [create a new project][new-project].
-Click on the **GitHub** link and, if you are logged in via the GitHub
-integration, you will be redirected to GitHub for permission to access your
-projects. After accepting, you'll be automatically redirected to the importer.
+If the [GitHub integration][gh-import] is enabled by your GitLab administrator,
+you can use it instead of the personal access token.
+
+1. First you may want to connect your GitHub account to GitLab in order for
+ the username mapping to be correct. Follow the [social sign-in] documentation
+ on how to do so.
+1. Once you connect GitHub, click the **List your GitHub repositories** button
+ and you will be redirected to GitHub for permission to access your projects.
+1. After accepting, you'll be automatically redirected to the importer.
+
+You can now go on and [select which repositories to import](#select-which-repositories-to-import).
+
+### Authorize access to your repositories using a personal access token
+
+>**Note:**
+For a proper author/assignee mapping for issues and pull requests, the
+[GitHub integration][gh-integration] should be used instead of the
+[personal access token][gh-token]. If the GitHub integration is enabled by your
+GitLab administrator, it should be the preferred method to import your repositories.
+Read more in the [How it works](#how-it-works) section.
If you are not using the GitHub integration, you can still perform a one-off
-authorization with GitHub to access your projects.
+authorization with GitHub to grant GitLab access your repositories:
-Alternatively, you can also enter a GitHub Personal Access Token. Once you enter
-your token, you'll be taken to the importer.
+1. Go to <https://github.com/settings/tokens/new>.
+1. Enter a token description.
+1. Check the `repo` scope.
+1. Click **Generate token**.
+1. Copy the token hash.
+1. Go back to GitLab and provide the token to the GitHub importer.
+1. Hit the **List your GitHub repositories** button and wait while GitLab reads
+ your repositories' information. Once done, you'll be taken to the importer
+ page to select the repositories to import.
-
+### Select which repositories to import
----
+After you've authorized access to your GitHub repositories, you will be
+redirected to the GitHub importer page.
+
+From there, you can see the import statuses of your GitHub repositories.
+
+- Those that are being imported will show a _started_ status,
+- those already successfully imported will be green with a _done_ status,
+- whereas those that are not yet imported will have an **Import** button on the
+ right side of the table.
-While at the GitHub importer page, you can see the import statuses of your
-GitHub projects. Those that are being imported will show a _started_ status,
-those already imported will be green, whereas those that are not yet imported
-have an **Import** button on the right side of the table. If you want, you can
-import all your GitHub projects in one go by hitting **Import all projects**
-in the upper left corner.
+If you want, you can import all your GitHub projects in one go by hitting
+**Import all projects** in the upper left corner.

---
-The importer will create any new namespaces if they don't exist or in the
-case the namespace is taken, the project will be imported on the user's
-namespace.
+You can also choose a different name for the project and a different namespace,
+if you have the privileges to do so.
[gh-import]: ../../integration/github.md "GitHub integration"
-[ee-gh]: http://docs.gitlab.com/ee/integration/github.html "GitHub integration for GitLab EE"
[new-project]: ../../gitlab-basics/create-project.md "How to create a new project in GitLab"
+[gh-integration]: #authorize-access-to-your-repositories-using-the-github-integration
+[gh-token]: #authorize-access-to-your-repositories-using-a-personal-access-token
+[social sign-in]: ../../profile/account/social_sign_in.md
diff --git a/doc/workflow/importing/migrating_from_svn.md b/doc/workflow/importing/migrating_from_svn.md index 4828bb5dce6..423b095e69e 100644 --- a/doc/workflow/importing/migrating_from_svn.md +++ b/doc/workflow/importing/migrating_from_svn.md @@ -4,6 +4,112 @@ Subversion (SVN) is a central version control system (VCS) while Git is a distributed version control system. There are some major differences between the two, for more information consult your favorite search engine. +## Overview + +There are two approaches to SVN to Git migration: + +1. [Git/SVN Mirror](#smooth-migration-with-a-gitsvn-mirror-using-subgit) which: + - Makes the GitLab repository to mirror the SVN project. + - Git and SVN repositories are kept in sync; you can use either one. + - Smoothens the migration process and allows to manage migration risks. + +1. [Cut over migration](#cut-over-migration-with-svn2git) which: + - Translates and imports the existing data and history from SVN to Git. + - Is a fire and forget approach, good for smaller teams. + +## Smooth migration with a Git/SVN mirror using SubGit + +[SubGit](https://subgit.com) is a tool for a smooth, stress-free SVN to Git +migration. It creates a writable Git mirror of a local or remote Subversion +repository and that way you can use both Subversion and Git as long as you like. +It requires access to your GitLab server as it talks with the Git repositories +directly in a filesystem level. + +### SubGit prerequisites + +1. Install Oracle JRE 1.8 or newer. On Debian-based Linux distributions you can + follow [this article](http://www.webupd8.org/2012/09/install-oracle-java-8-in-ubuntu-via-ppa.html). +1. Download SubGit from https://subgit.com/download/. +1. Unpack the downloaded SubGit zip archive to the `/opt` directory. The `subgit` + command will be available at `/opt/subgit-VERSION/bin/subgit`. + +### SubGit configuration + +The first step to mirror you SVN repository in GitLab is to create a new empty +project which will be used as a mirror. For Omnibus installations the path to +the repository will be located at +`/var/opt/gitlab/git-data/repositories/USER/REPO.git` by default. For +installations from source, the default repository directory will be +`/home/git/repositories/USER/REPO.git`. For convenience, assign this path to a +variable: + +``` +GIT_REPO_PATH=/var/opt/gitlab/git-data/repositories/USER/REPOS.git +``` + +SubGit will keep this repository in sync with a remote SVN project. For +convenience, assign your remote SVN project URL to a variable: + +``` +SVN_PROJECT_URL=http://svn.company.com/repos/project +``` + +Next you need to run SubGit to set up a Git/SVN mirror. Make sure the following +`subgit` command is ran on behalf of the same user that keeps ownership of +GitLab Git repositories (by default `git`): + +``` +subgit configure --layout auto $SVN_PROJECT_URL $GIT_REPO_PATH +``` + +Adjust authors and branches mappings, if necessary. Open with your favorite +text editor: + +``` +edit $GIT_REPO_PATH/subgit/authors.txt +edit $GIT_REPO_PATH/subgit/config +``` + +For more information regarding the SubGit configuration options, refer to +[SubGit's documentation](https://subgit.com/documentation.html) website. + +### Initial translation + +Now that SubGit has configured the Git/SVN repos, run `subgit` to perform the +initial translation of existing SVN revisions into the Git repository: + +``` +subgit install $GIT_REPOS_PATH +``` + +After the initial translation is completed, the Git repository and the SVN +project will be kept in sync by `subgit` - new Git commits will be translated to +SVN revisions and new SVN revisions will be translated to Git commits. Mirror +works transparently and does not require any special commands. + +If you would prefer to perform one-time cut over migration with `subgit`, use +the `import` command instead of `install`: + +``` +subgit import $GIT_REPO_PATH +``` + +### SubGit licensing + +Running SubGit in a mirror mode requires a +[registration](https://subgit.com/pricing.html). Registration is free for open +source, academic and startup projects. + +We're currently working on deeper GitLab/SubGit integration. You may track our +progress at [this issue](https://gitlab.com/gitlab-org/gitlab-ee/issues/990). + +### SubGit support + +For any questions related to SVN to GitLab migration with SubGit, you can +contact the SubGit team directly at [support@subgit.com](mailto:support@subgit.com). + +## Cut over migration with svn2git + If you are currently using an SVN repository, you can migrate the repository to Git and GitLab. We recommend a hard cut over - run the migration command once and then have all developers start using the new GitLab repository immediately. @@ -75,5 +181,3 @@ git push --tags origin ## Contribute to this guide We welcome all contributions that would expand this guide with instructions on how to migrate from SVN and other version control systems. - - diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md index 9dc1e9b47e3..b3c73e947f0 100644 --- a/doc/workflow/lfs/lfs_administration.md +++ b/doc/workflow/lfs/lfs_administration.md @@ -45,5 +45,5 @@ In `config/gitlab.yml`: * Currently, storing GitLab Git LFS objects on a non-local storage (like S3 buckets) is not supported * Currently, removing LFS objects from GitLab Git LFS storage is not supported -* LFS authentications via SSH is not supported for the time being -* Only compatible with the GitLFS client versions 1.1.0 or 1.0.2. +* LFS authentications via SSH was added with GitLab 8.12 +* Only compatible with the GitLFS client versions 1.1.0 and up, or 1.0.2. diff --git a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md index 9fe065fa680..1a4f213a792 100644 --- a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md +++ b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md @@ -35,6 +35,10 @@ Documentation for GitLab instance administrators is under [LFS administration do credentials store is recommended * Git LFS always assumes HTTPS so if you have GitLab server on HTTP you will have to add the URL to Git config manually (see #troubleshooting) + +>**Note**: With 8.12 GitLab added LFS support to SSH. The Git LFS communication + still goes over HTTP, but now the SSH client passes the correct credentials + to the Git LFS client, so no action is required by the user. ## Using Git LFS @@ -132,6 +136,10 @@ git config --add lfs.url "http://gitlab.example.com/group/project.git/info/lfs" ### Credentials are always required when pushing an object +>**Note**: With 8.12 GitLab added LFS support to SSH. The Git LFS communication + still goes over HTTP, but now the SSH client passes the correct credentials + to the Git LFS client, so no action is required by the user. + Given that Git LFS uses HTTP Basic Authentication to authenticate the user pushing the LFS object on every push for every object, user HTTPS credentials are required. diff --git a/doc/workflow/merge_requests.md b/doc/workflow/merge_requests.md index d2ec56e6504..a68bb8b27ca 100644 --- a/doc/workflow/merge_requests.md +++ b/doc/workflow/merge_requests.md @@ -1,63 +1 @@ -# Merge Requests - -Merge requests allow you to exchange changes you made to source code - -## Only allow merge requests to be merged if the build succeeds - -You can prevent merge requests from being merged if their build did not succeed -in the project settings page. - - - -Navigate to project settings page and select the `Only allow merge requests to be merged if the build succeeds` check box. - -Please note that you need to have builds configured to enable this feature. - -## Checkout merge requests locally - -Locate the section for your GitLab remote in the `.git/config` file. It looks like this: - -``` -[remote "origin"] - url = https://gitlab.com/gitlab-org/gitlab-ce.git - fetch = +refs/heads/*:refs/remotes/origin/* -``` - -Now add the line `fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/*` to this section. - -It should look like this: - -``` -[remote "origin"] - url = https://gitlab.com/gitlab-org/gitlab-ce.git - fetch = +refs/heads/*:refs/remotes/origin/* - fetch = +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* -``` - -Now you can fetch all the merge requests requests: - -``` -$ git fetch origin -From https://gitlab.com/gitlab-org/gitlab-ce.git - * [new ref] refs/merge-requests/1/head -> origin/merge-requests/1 - * [new ref] refs/merge-requests/2/head -> origin/merge-requests/2 -... -``` - -To check out a particular merge request: - -``` -$ git checkout origin/merge-requests/1 -``` - -## Ignore whitespace changes in Merge Request diff view - - - -If you click the "Hide whitespace changes" button, you can see the diff without whitespace changes. - - - -It is also working on commits compare view. - - +This document was moved to [user/project/merge_requests](../user/project/merge_requests.md). diff --git a/doc/workflow/merge_requests/merge_request_diff.png b/doc/workflow/merge_requests/merge_request_diff.png Binary files differdeleted file mode 100644 index 3ebbfb75ea3..00000000000 --- a/doc/workflow/merge_requests/merge_request_diff.png +++ /dev/null diff --git a/doc/workflow/merge_requests/merge_request_diff_without_whitespace.png b/doc/workflow/merge_requests/merge_request_diff_without_whitespace.png Binary files differdeleted file mode 100644 index a0db535019c..00000000000 --- a/doc/workflow/merge_requests/merge_request_diff_without_whitespace.png +++ /dev/null diff --git a/doc/workflow/merge_when_build_succeeds.md b/doc/workflow/merge_when_build_succeeds.md index 75e1fdff2b2..95afd12ebdb 100644 --- a/doc/workflow/merge_when_build_succeeds.md +++ b/doc/workflow/merge_when_build_succeeds.md @@ -1,15 +1 @@ -# Merge When Build Succeeds - -When reviewing a merge request that looks ready to merge but still has one or more CI builds running, you can set it to be merged automatically when all builds succeed. This way, you don't have to wait for the builds to finish and remember to merge the request manually. - - - -When you hit the "Merge When Build Succeeds" button, the status of the merge request will be updated to represent the impending merge. If you cannot wait for the build to succeed and want to merge immediately, this option is available in the dropdown menu on the right of the main button. - -Both team developers and the author of the merge request have the option to cancel the automatic merge if they find a reason why it shouldn't be merged after all. - - - -When the build succeeds, the merge request will automatically be merged. When the build fails, the author gets a chance to retry any failed builds, or to push new commits to fix the failure. - -When the builds are retried and succeed on the second try, the merge request will automatically be merged after all. When the merge request is updated with new commits, the automatic merge is automatically canceled to allow the new changes to be reviewed. +This document was moved to [user/project/merge_requests/merge_when_build_succeeds](../user/project/merge_requests/merge_when_build_succeeds.md). diff --git a/doc/workflow/notifications.md b/doc/workflow/notifications.md index b4a9c2f3d3e..1b49a5c385f 100644 --- a/doc/workflow/notifications.md +++ b/doc/workflow/notifications.md @@ -67,7 +67,7 @@ In all of the below cases, the notification will be sent to: - Participants: - the author and assignee of the issue/merge request - authors of comments on the issue/merge request - - anyone mentioned by `@username` in the issue/merge request description + - anyone mentioned by `@username` in the issue/merge request title or description - anyone mentioned by `@username` in any of the comments on the issue/merge request ...with notification level "Participating" or higher @@ -89,6 +89,11 @@ In all of the below cases, the notification will be sent to: | Merge merge request | | | New comment | The above, plus anyone mentioned by `@username` in the comment, with notification level "Mention" or higher | + +In addition, if the title or description of an Issue or Merge Request is +changed, notifications will be sent to any **new** mentions by `@username` as +if they had been mentioned in the original text. + You won't receive notifications for Issues, Merge Requests or Milestones created by yourself. You will only receive automatic notifications when somebody else comments or adds changes to the ones that you've created or diff --git a/doc/workflow/project_features.md b/doc/workflow/project_features.md index a523b3facbe..f19e7df8c9a 100644 --- a/doc/workflow/project_features.md +++ b/doc/workflow/project_features.md @@ -32,4 +32,12 @@ Snippets are little bits of code or text. This is a nice place to put code or text that is used semi-regularly within the project, but does not belong in source control. -For example, a specific config file that is used by > the team that is only valid for the people that work on the code. +For example, a specific config file that is used by the team that is only valid for the people that work on the code. + +## Git LFS + +>**Note:** Project-specific LFS setting was added on 8.12 and is available only to admins. + +Git Large File Storage allows you to easily manage large binary files with Git. +With this setting admins can better control which projects are allowed to use +LFS. diff --git a/doc/workflow/revert_changes.md b/doc/workflow/revert_changes.md index 399366b0cdc..cf1292253fc 100644 --- a/doc/workflow/revert_changes.md +++ b/doc/workflow/revert_changes.md @@ -1,64 +1 @@ -# Reverting changes - -_**Note:** This feature was [introduced][ce-1990] in GitLab 8.5._ - ---- - -GitLab implements Git's powerful feature to [revert any commit][git-revert] -with introducing a **Revert** button in Merge Requests and commit details. - -## Reverting a Merge Request - -_**Note:** The **Revert** button will only be available for Merge Requests -created since GitLab 8.5. However, you can still revert a Merge Request -by reverting the merge commit from the list of Commits page._ - -After the Merge Request has been merged, a **Revert** button will be available -to revert the changes introduced by that Merge Request: - - - ---- - -You can revert the changes directly into the selected branch or you can opt to -create a new Merge Request with the revert changes: - - - ---- - -After the Merge Request has been reverted, the **Revert** button will not be -available anymore. - -## Reverting a Commit - -You can revert a Commit from the Commit details page: - - - ---- - -Similar to reverting a Merge Request, you can opt to revert the changes -directly into the target branch or create a new Merge Request to revert the -changes: - - - ---- - -After the Commit has been reverted, the **Revert** button will not be available -anymore. - -Please note that when reverting merge commits, the mainline will always be the -first parent. If you want to use a different mainline then you need to do that -from the command line. - -Here is a quick example to revert a merge commit using the second parent as the -mainline: - -```bash -git revert -m 2 7a39eb0 -``` - -[ce-1990]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1990 "Revert button Merge Request" -[git-revert]: https://git-scm.com/docs/git-revert "Git revert documentation" +This document was moved to [user/project/merge_requests/revert_changes](../user/project/merge_requests/revert_changes.md). diff --git a/doc/workflow/share_projects_with_other_groups.md b/doc/workflow/share_projects_with_other_groups.md index 4c59f59c587..8e50cb03e63 100644 --- a/doc/workflow/share_projects_with_other_groups.md +++ b/doc/workflow/share_projects_with_other_groups.md @@ -1,22 +1,24 @@ # Share Projects with other Groups -In GitLab Enterprise Edition you can share projects with other groups. -This makes it possible to add a group of users to a project with a single action. +You can share projects with other groups. This makes it possible to add a group of users +to a project with a single action. ## Groups as collections of users -In GitLab Community Edition groups are used primarily to [create collections of projects](groups.md). -In GitLab Enterprise Edition you can also take advantage of the fact that groups define collections of _users_, namely the group members. +Groups are used primarily to [create collections of projects](groups.md), but you can also +take advantage of the fact that groups define collections of _users_, namely the group +members. ## Sharing a project with a group of users -The primary mechanism to give a group of users, say 'Engineering', access to a project, say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project Acme'. -But what if 'Project Acme' already belongs to another group, say 'Open Source'? -This is where the (Enterprise Edition only) group sharing feature can be of use. +The primary mechanism to give a group of users, say 'Engineering', access to a project, +say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project +Acme'. But what if 'Project Acme' already belongs to another group, say 'Open Source'? +This is where the group sharing feature can be of use. To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the 'Groups' section. - + Now you can add the 'Engineering' group with the maximum access level of your choice. After sharing 'Project Acme' with 'Engineering', the project is listed on the group dashboard. diff --git a/doc/workflow/shortcuts.md b/doc/workflow/shortcuts.md index ffcb832cdd7..36516883ef6 100644 --- a/doc/workflow/shortcuts.md +++ b/doc/workflow/shortcuts.md @@ -2,4 +2,75 @@ You can see GitLab's keyboard shortcuts by using 'shift + ?' -
\ No newline at end of file +## Global Shortcuts + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>s</kbd> | Focus search | +| <kbd>?</kbd> | Show/hide this dialog | +| <kbd>⌘</kbd> + <kbd>shift</kbd> + <kbd>p</kbd> | Toggle markdown preview | +| <kbd>↑</kbd> | Edit last comment (when focused on an empty textarea) | + +## Project Files Browsing + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>↑</kbd> | Move selection up | +| <kbd>↓</kbd> | Move selection down | +| <kbd>enter</kbd> | Open selection | + +## Finding Project File + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>↑</kbd> | Move selection up | +| <kbd>↓</kbd> | Move selection down | +| <kbd>enter</kbd> | Open selection | +| <kbd>esc</kbd> | Go back | + +## Global Dashboard + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>g</kbd> + <kbd>a</kbd> | Go to the activity feed | +| <kbd>g</kbd> + <kbd>p</kbd> | Go to projects | +| <kbd>g</kbd> + <kbd>i</kbd> | Go to issues | +| <kbd>g</kbd> + <kbd>m</kbd> | Go to merge requests | + +## Project + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>g</kbd> + <kbd>p</kbd> | Go to the project's home page | +| <kbd>g</kbd> + <kbd>e</kbd> | Go to the project's activity feed | +| <kbd>g</kbd> + <kbd>f</kbd> | Go to files | +| <kbd>g</kbd> + <kbd>c</kbd> | Go to commits | +| <kbd>g</kbd> + <kbd>b</kbd> | Go to builds | +| <kbd>g</kbd> + <kbd>n</kbd> | Go to network graph | +| <kbd>g</kbd> + <kbd>g</kbd> | Go to graphs | +| <kbd>g</kbd> + <kbd>i</kbd> | Go to issues | +| <kbd>g</kbd> + <kbd>m</kbd> | Go to merge requests | +| <kbd>g</kbd> + <kbd>s</kbd> | Go to snippets | +| <kbd>t</kbd> | Go to finding file | +| <kbd>i</kbd> | New issue | + +## Network Graph + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>←</kbd> or <kbd>h</kbd> | Scroll left | +| <kbd>→</kbd> or <kbd>l</kbd> | Scroll right | +| <kbd>↑</kbd> or <kbd>k</kbd> | Scroll up | +| <kbd>↓</kbd> or <kbd>j</kbd> | Scroll down | +| <kbd>shift</kbd> + <kbd>↑</kbd> or <kbd>shift</kbd> + <kbd>k</kbd> | Scroll to top | +| <kbd>shift</kbd> + <kbd>↓</kbd> or <kbd>shift</kbd> + <kbd>j</kbd> | Scroll to bottom | + +## Issues and Merge Requests + +| Keyboard Shortcut | Description | +| ----------------- | ----------- | +| <kbd>a</kbd> | Change assignee | +| <kbd>m</kbd> | Change milestone | +| <kbd>r</kbd> | Reply (quoting selected text) | +| <kbd>e</kbd> | Edit issue/merge request | +| <kbd>l</kbd> | Change label |
\ No newline at end of file diff --git a/doc/workflow/shortcuts.png b/doc/workflow/shortcuts.png Binary files differdeleted file mode 100644 index a9b1c4b4dcc..00000000000 --- a/doc/workflow/shortcuts.png +++ /dev/null diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md index 9524ffd5420..a50ba305deb 100644 --- a/doc/workflow/todos.md +++ b/doc/workflow/todos.md @@ -1,6 +1,6 @@ # GitLab Todos ->**Note:** This feature was [introduced][ce-2817] in GitLab 8.5. +> [Introduced][ce-2817] in GitLab 8.5. When you log into GitLab, you normally want to see where you should spend your time and take some action, or what you need to keep an eye on. All without the diff --git a/doc/workflow/web_editor.md b/doc/workflow/web_editor.md index 1832567a34c..595c7da155b 100644 --- a/doc/workflow/web_editor.md +++ b/doc/workflow/web_editor.md @@ -1,152 +1 @@ -# GitLab Web Editor - -Sometimes it's easier to make quick changes directly from the GitLab interface -than to clone the project and use the Git command line tool. In this feature -highlight we look at how you can create a new file, directory, branch or -tag from the file browser. All of these actions are available from a single -dropdown menu. - -## Create a file - -From a project's files page, click the '+' button to the right of the branch selector. -Choose **New file** from the dropdown. - - - ---- - -Enter a file name in the **File name** box. Then, add file content in the editor -area. Add a descriptive commit message and choose a branch. The branch field -will default to the branch you were viewing in the file browser. If you enter -a new branch name, a checkbox will appear allowing you to start a new merge -request after you commit the changes. - -When you are satisfied with your new file, click **Commit Changes** at the bottom. - - - -## Upload a file - -The ability to create a file is great when the content is text. However, this -doesn't work well for binary data such as images, PDFs or other file types. In -this case you need to upload a file. - -From a project's files page, click the '+' button to the right of the branch -selector. Choose **Upload file** from the dropdown. - - - ---- - -Once the upload dialog pops up there are two ways to upload your file. Either -drag and drop a file on the pop up or use the **click to upload** link. A file -preview will appear once you have selected a file to upload. - -Enter a commit message, choose a branch, and click **Upload file** when you are -ready. - - - -## Create a directory - -To keep files in the repository organized it is often helpful to create a new -directory. - -From a project's files page, click the '+' button to the right of the branch selector. -Choose **New directory** from the dropdown. - - - ---- - -In the new directory dialog enter a directory name, a commit message and choose -the target branch. Click **Create directory** to finish. - - - -## Create a new branch - -There are multiple ways to create a branch from GitLab's web interface. - -### Create a new branch from an issue - ->**Note:** -This feature was [introduced][ce-2808] in GitLab 8.6. - -In case your development workflow dictates to have an issue for every merge -request, you can quickly create a branch right on the issue page which will be -tied with the issue itself. You can see a **New Branch** button after the issue -description, unless there is already a branch with the same name or a referenced -merge request. - - - -Once you click it, a new branch will be created that diverges from the default -branch of your project, by default `master`. The branch name will be based on -the title of the issue and as suffix it will have its ID. Thus, the example -screenshot above will yield a branch named -`2-et-cum-et-sed-expedita-repellat-consequatur-ut-assumenda-numquam-rerum`. - -After the branch is created, you can edit files in the repository to fix -the issue. When a merge request is created based on the newly created branch, -the description field will automatically display the [issue closing pattern] -`Closes #ID`, where `ID` the ID of the issue. This will close the issue once the -merge request is merged. - -### Create a new branch from a project's dashboard - -If you want to make changes to several files before creating a new merge -request, you can create a new branch up front. From a project's files page, -choose **New branch** from the dropdown. - - - ---- - -Enter a new **Branch name**. Optionally, change the **Create from** field -to choose which branch, tag or commit SHA this new branch will originate from. -This field will autocomplete if you start typing an existing branch or tag. -Click **Create branch** and you will be returned to the file browser on this new -branch. - - - ---- - -You can now make changes to any files, as needed. When you're ready to merge -the changes back to master you can use the widget at the top of the screen. -This widget only appears for a period of time after you create the branch or -modify files. - - - -## Create a new tag - -Tags are useful for marking major milestones such as production releases, -release candidates, and more. You can create a tag from a branch or a commit -SHA. From a project's files page, choose **New tag** from the dropdown. - - - ---- - -Give the tag a name such as `v1.0.0`. Choose the branch or SHA from which you -would like to create this new tag. You can optionally add a message and -release notes. The release notes section supports markdown format and you can -also upload an attachment. Click **Create tag** and you will be taken to the tag -list page. - - - -## Tips - -When creating or uploading a new file, or creating a new directory, you can -trigger a new merge request rather than committing directly to master. Enter -a new branch name in the **Target branch** field. You will notice a checkbox -appear that is labeled **Start a new merge request with these changes**. After -you commit the changes you will be taken to a new merge request form. - - - -[ce-2808]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2808 -[issue closing pattern]: ../customization/issue_closing.md +This document was moved to [user/project/repository/web_editor](../user/project/repository/web_editor.md). diff --git a/doc/workflow/wip_merge_requests.md b/doc/workflow/wip_merge_requests.md index 46035a5e6b6..abb8002f442 100644 --- a/doc/workflow/wip_merge_requests.md +++ b/doc/workflow/wip_merge_requests.md @@ -1,13 +1 @@ -# "Work In Progress" Merge Requests - -To prevent merge requests from accidentally being accepted before they're completely ready, GitLab blocks the "Accept" button for merge requests that have been marked a **Work In Progress**. - - - -To mark a merge request a Work In Progress, simply start its title with `[WIP]` or `WIP:`. - - - -To allow a Work In Progress merge request to be accepted again when it's ready, simply remove the `WIP` prefix. - - +This document was moved to [user/project/merge_requests/work_in_progress_merge_requests](../user/project/merge_requests/work_in_progress_merge_requests.md). diff --git a/features/dashboard/new_project.feature b/features/dashboard/new_project.feature index 8ddafb6a7ac..046e2815d4e 100644 --- a/features/dashboard/new_project.feature +++ b/features/dashboard/new_project.feature @@ -9,7 +9,7 @@ Background: @javascript Scenario: I should see New Projects page Then I see "New Project" page - Then I see all possible import optios + Then I see all possible import options @javascript Scenario: I should see instructions on how to import from Git URL diff --git a/features/dashboard/todos.feature b/features/dashboard/todos.feature index 42f5d6d2af7..0b23bbb7951 100644 --- a/features/dashboard/todos.feature +++ b/features/dashboard/todos.feature @@ -23,26 +23,6 @@ Feature: Dashboard Todos Then I should see all todos marked as done @javascript - Scenario: I filter by project - Given I filter by "Enterprise" - Then I should not see todos - - @javascript - Scenario: I filter by author - Given I filter by "John Doe" - Then I should not see todos related to "Mary Jane" in the list - - @javascript - Scenario: I filter by type - Given I filter by "Issue" - Then I should not see todos related to "Merge Requests" in the list - - @javascript - Scenario: I filter by action - Given I filter by "Mentioned" - Then I should not see todos related to "Assignments" in the list - - @javascript Scenario: I click on a todo row Given I click on the todo Then I should be directed to the corresponding page diff --git a/features/explore/groups.feature b/features/explore/groups.feature index 5fc9b135601..9eacbe0b25e 100644 --- a/features/explore/groups.feature +++ b/features/explore/groups.feature @@ -24,14 +24,6 @@ Feature: Explore Groups Then I should see project "Internal" items And I should not see project "Enterprise" items - Scenario: I should see group's members as user - Given group "TestGroup" has internal project "Internal" - And "John Doe" is owner of group "TestGroup" - When I sign in as a user - And I visit group "TestGroup" members page - Then I should see group member "John Doe" - And I should not see member roles - Scenario: I should see group with private, internal and public projects as visitor Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has public project "Community" @@ -56,14 +48,6 @@ Feature: Explore Groups And I should not see project "Internal" items And I should not see project "Enterprise" items - Scenario: I should see group's members as visitor - Given group "TestGroup" has internal project "Internal" - Given group "TestGroup" has public project "Community" - And "John Doe" is owner of group "TestGroup" - When I visit group "TestGroup" members page - Then I should see group member "John Doe" - And I should not see member roles - Scenario: I should see group with private, internal and public projects as user Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has public project "Community" @@ -91,15 +75,6 @@ Feature: Explore Groups And I should see project "Internal" items And I should not see project "Enterprise" items - Scenario: I should see group's members as user - Given group "TestGroup" has internal project "Internal" - Given group "TestGroup" has public project "Community" - And "John Doe" is owner of group "TestGroup" - When I sign in as a user - And I visit group "TestGroup" members page - Then I should see group member "John Doe" - And I should not see member roles - Scenario: I should see group with public project in public groups area Given group "TestGroup" has public project "Community" When I visit the public groups area diff --git a/features/profile/ssh_keys.feature b/features/profile/ssh_keys.feature deleted file mode 100644 index b0d5b748916..00000000000 --- a/features/profile/ssh_keys.feature +++ /dev/null @@ -1,20 +0,0 @@ -@profile -Feature: Profile SSH Keys - Background: - Given I sign in as a user - And I have ssh key "ssh-rsa Work" - And I visit profile keys page - - Scenario: I should see ssh keys - Then I should see my ssh keys - - Scenario: Add new ssh key - Given I should see new ssh key form - And I submit new ssh key "Laptop" - Then I should see new ssh key "Laptop" - - Scenario: Remove ssh key - Given I click link "Work" - And I click link "Remove" - Then I visit profile keys page - And I should not see "Work" ssh key diff --git a/features/project/commits/branches.feature b/features/project/commits/branches.feature index 2c17d32154a..88fef674c0c 100644 --- a/features/project/commits/branches.feature +++ b/features/project/commits/branches.feature @@ -22,6 +22,7 @@ Feature: Project Commits Branches @javascript Scenario: I delete a branch Given I visit project branches page + And I filter for branch improve/awesome And I click branch 'improve/awesome' delete link Then I should not see branch 'improve/awesome' diff --git a/features/project/commits/commits.feature b/features/project/commits/commits.feature index 8b0cb90765e..1776c07e60e 100644 --- a/features/project/commits/commits.feature +++ b/features/project/commits/commits.feature @@ -37,6 +37,11 @@ Feature: Project Commits Then I see commit info And I see side-by-side diff button + Scenario: I browse commit from list and create a new tag + Given I click on commit link + And I click on tag link + Then I see commit SHA pre-filled + Scenario: I browse commit with ci from list Given commit has ci status And repository contains ".gitlab-ci.yml" file diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 358e622b736..80670063ea0 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -37,6 +37,7 @@ Feature: Project Issues And I submit new issue "500 error on profile" Then I should see issue "500 error on profile" + @javascript Scenario: I submit new unassigned issue with labels Given project "Shop" has labels: "bug", "feature", "enhancement" And I click link "New Issue" diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index 21768c15c17..5aa592e9067 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -24,7 +24,7 @@ Feature: Project Merge Requests Scenario: I should see target branch when it is different from default Given project "Shop" have "Bug NS-06" open merge request When I visit project "Shop" merge requests page - Then I should see "other_branch" branch + Then I should see "feature_conflict" branch Scenario: I should not see the numbers of diverged commits if the branch is rebased on the target Given project "Shop" have "Bug NS-07" open merge request with rebased branch @@ -89,7 +89,7 @@ Feature: Project Merge Requests Then The list should be sorted by "Oldest updated" @javascript - Scenario: Visiting Merge Requests from a differente Project after sorting + Scenario: Visiting Merge Requests from a different Project after sorting Given I visit project "Shop" merge requests page And I sort the list by "Oldest updated" And I visit dashboard merge requests page @@ -237,6 +237,15 @@ Feature: Project Merge Requests Then I should see additional file lines @javascript + Scenario: I unfold diff in Side-by-Side view + Given project "Shop" have "Bug NS-05" open merge request with diffs inside + And I visit merge request page "Bug NS-05" + And I click on the Changes tab + And I click Side-by-side Diff tab + And I unfold diff + Then I should see additional file lines + + @javascript Scenario: I show comments on a merge request side-by-side diff with comments in multiple files Given project "Shop" have "Bug NS-05" open merge request with diffs inside And I visit merge request page "Bug NS-05" diff --git a/features/project/snippets.feature b/features/project/snippets.feature index 270557cbde7..3c51ea56585 100644 --- a/features/project/snippets.feature +++ b/features/project/snippets.feature @@ -12,7 +12,7 @@ Feature: Project Snippets And I should not see "Snippet two" in snippets Scenario: I create new project snippet - Given I click link "New Snippet" + Given I click link "New snippet" And I submit new snippet "Snippet three" Then I should see snippet "Snippet three" diff --git a/features/project/source/browse_files.feature b/features/project/source/browse_files.feature index fdffd71de85..d4b91fec6e8 100644 --- a/features/project/source/browse_files.feature +++ b/features/project/source/browse_files.feature @@ -71,6 +71,7 @@ Feature: Project Source Browse Files And I fill the new branch name And I click on "Commit Changes" Then I am redirected to the new merge request page + When I click on "Changes" tab And I should see its new content @javascript @@ -80,9 +81,10 @@ Feature: Project Source Browse Files And I fill the upload file commit message And I fill the new branch name And I click on "Upload file" - Then I can see the new text file + Then I can see the new commit message And I am redirected to the new merge request page - And I can see the new commit message + When I click on "Changes" tab + Then I can see the new text file @javascript Scenario: I can upload file and commit when I don't have write access @@ -93,9 +95,10 @@ Feature: Project Source Browse Files And I upload a new text file And I fill the upload file commit message And I click on "Upload file" - Then I can see the new text file + Then I can see the new commit message And I am redirected to the fork's new merge request page - And I can see the new commit message + When I click on "Changes" tab + Then I can see the new text file @javascript Scenario: I can replace file and commit @@ -119,9 +122,10 @@ Feature: Project Source Browse Files And I replace it with a text file And I fill the replace file commit message And I click on "Replace file" - Then I can see the new text file - And I am redirected to the fork's new merge request page And I can see the replacement commit message + And I am redirected to the fork's new merge request page + When I click on "Changes" tab + Then I can see the new text file @javascript Scenario: If I enter an illegal file name I see an error message @@ -191,6 +195,7 @@ Feature: Project Source Browse Files And I fill the new branch name And I click on "Commit Changes" Then I am redirected to the new merge request page + Then I click on "Changes" tab And I should see its new content @javascript @wip diff --git a/features/steps/admin/settings.rb b/features/steps/admin/settings.rb index 03f87df7a60..11dc7f580f0 100644 --- a/features/steps/admin/settings.rb +++ b/features/steps/admin/settings.rb @@ -33,6 +33,7 @@ class Spinach::Features::AdminSettings < Spinach::FeatureSteps page.check('Issue') page.check('Merge request') page.check('Build') + page.check('Pipeline') click_on 'Save' end diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index 80ed4c6d64c..a7d61bc28e0 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -26,6 +26,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps end step 'I see prefilled new Merge Request page' do + expect(page).to have_selector('.merge-request-form') expect(current_path).to eq new_namespace_project_merge_request_path(@project.namespace, @project) expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s expect(find("input#merge_request_source_branch").value).to eq "fix" diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index 726b37cfde5..ca3cd0ecc4e 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -1,4 +1,5 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps + include WaitForAjax include SharedAuthentication include SharedPaths include SharedProject @@ -72,14 +73,20 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps end When 'I click "push" event filter' do - click_link("push_event_filter") + wait_for_ajax + click_link("Push events") + wait_for_ajax end When 'I click "team" event filter' do - click_link("team_event_filter") + wait_for_ajax + click_link("Team") + wait_for_ajax end When 'I click "merge" event filter' do - click_link("merged_event_filter") + wait_for_ajax + click_link("Merge events") + wait_for_ajax end end diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index 8706f0e8e78..39c65bb6cde 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -43,9 +43,14 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps step 'I click "All" link' do find(".js-author-search").click + expect(page).to have_selector(".dropdown-menu-author li a") find(".dropdown-menu-author li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-author li a") + find(".js-assignee-search").click + expect(page).to have_selector(".dropdown-menu-assignee li a") find(".dropdown-menu-assignee li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-assignee li a") end def should_see(issue) diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb index 06db36c7014..6777101fb15 100644 --- a/features/steps/dashboard/merge_requests.rb +++ b/features/steps/dashboard/merge_requests.rb @@ -47,9 +47,14 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps step 'I click "All" link' do find(".js-author-search").click + expect(page).to have_selector(".dropdown-menu-author li a") find(".dropdown-menu-author li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-author li a") + find(".js-assignee-search").click + expect(page).to have_selector(".dropdown-menu-assignee li a") find(".dropdown-menu-assignee li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-assignee li a") end def should_see(merge_request) diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index 727a6a71373..cb36d6ae1a9 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -14,11 +14,10 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps expect(page).to have_content('Project name') end - step 'I see all possible import optios' do + step 'I see all possible import options' do expect(page).to have_link('GitHub') expect(page).to have_link('Bitbucket') expect(page).to have_link('GitLab.com') - expect(page).to have_link('Gitorious.org') expect(page).to have_link('Google Code') expect(page).to have_link('Repo by URL') expect(page).to have_link('GitLab export') @@ -29,6 +28,7 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps end step 'I am redirected to the GitHub import page' do + expect(page).to have_content('Import Projects from GitHub') expect(current_path).to eq new_import_github_path end @@ -47,6 +47,7 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps end step 'I redirected to Google Code import page' do + expect(page).to have_content('Import projects from Google Code') expect(current_path).to eq new_import_google_code_path end end diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index 60152d3da55..344b6fda9a6 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -3,7 +3,6 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps include SharedPaths include SharedProject include SharedUser - include Select2Helper step '"John Doe" is a developer of project "Shop"' do project.team << [john_doe, :developer] @@ -54,7 +53,8 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps page.within('.todos-pending-count') { expect(page).to have_content '0' } expect(page).to have_content 'To do 0' expect(page).to have_content 'Done 4' - expect(page).not_to have_link project.name_with_namespace + expect(page).to have_content "You're all done!" + expect('.prepend-top-default').not_to have_link project.name_with_namespace should_not_see_todo "John Doe assigned you merge request #{merge_request.to_reference}" should_not_see_todo "John Doe mentioned you on issue #{issue.to_reference}" should_not_see_todo "John Doe assigned you issue #{issue.to_reference}" @@ -79,19 +79,31 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps end step 'I filter by "Enterprise"' do - select2(enterprise.id, from: "#project_id") + click_button 'Project' + page.within '.dropdown-menu-project' do + click_link enterprise.name_with_namespace + end end step 'I filter by "John Doe"' do - select2(john_doe.id, from: "#author_id") + click_button 'Author' + page.within '.dropdown-menu-author' do + click_link john_doe.username + end end step 'I filter by "Issue"' do - select2('Issue', from: "#type") + click_button 'Type' + page.within '.dropdown-menu-type' do + click_link 'Issue' + end end step 'I filter by "Mentioned"' do - select2("#{Todo::MENTIONED}", from: '#action_id') + click_button 'Action' + page.within '.dropdown-menu-action' do + click_link 'Mentioned' + end end step 'I should not see todos' do diff --git a/features/steps/explore/groups.rb b/features/steps/explore/groups.rb index 87f32e70d59..409bf0cb416 100644 --- a/features/steps/explore/groups.rb +++ b/features/steps/explore/groups.rb @@ -62,10 +62,6 @@ class Spinach::Features::ExploreGroups < Spinach::FeatureSteps expect(page).to have_content "John Doe" end - step 'I should not see member roles' do - expect(body).not_to match(%r{owner|developer|reporter|guest}i) - end - protected def group_has_project(groupname, projectname, visibility_level) diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index dfa2fa75def..e9b45823c67 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -116,8 +116,8 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - click_button "Edit access level" - select 'Developer', from: 'group_member_access_level' + click_button 'Edit' + select 'Developer', from: "member_access_level_#{member.id}" click_on 'Save' end end diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 4ee6784a086..05ab2a7dc73 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -13,6 +13,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps fill_in 'user_website_url', with: 'testurl' fill_in 'user_location', with: 'Ukraine' fill_in 'user_bio', with: 'I <3 GitLab' + fill_in 'user_organization', with: 'GitLab' click_button 'Update profile settings' @user.reload end @@ -23,6 +24,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps expect(@user.twitter).to eq 'testtwitter' expect(@user.website_url).to eq 'testurl' expect(@user.bio).to eq 'I <3 GitLab' + expect(@user.organization).to eq 'GitLab' expect(find('#user_location').value).to eq 'Ukraine' end diff --git a/features/steps/profile/ssh_keys.rb b/features/steps/profile/ssh_keys.rb deleted file mode 100644 index a400488a532..00000000000 --- a/features/steps/profile/ssh_keys.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Spinach::Features::ProfileSshKeys < Spinach::FeatureSteps - include SharedAuthentication - - step 'I should see my ssh keys' do - @user.keys.each do |key| - expect(page).to have_content(key.title) - end - end - - step 'I should see new ssh key form' do - expect(page).to have_content("Add an SSH key") - end - - step 'I submit new ssh key "Laptop"' do - fill_in "key_title", with: "Laptop" - fill_in "key_key", with: "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop" - click_button "Add key" - end - - step 'I should see new ssh key "Laptop"' do - key = Key.find_by(title: "Laptop") - expect(page).to have_content(key.title) - expect(page).to have_content(key.key) - expect(current_path).to eq profile_key_path(key) - end - - step 'I click link "Work"' do - click_link "Work" - end - - step 'I click link "Remove"' do - click_link "Remove" - end - - step 'I visit profile keys page' do - visit profile_keys_path - end - - step 'I should not see "Work" ssh key' do - expect(page).not_to have_content "Work" - end - - step 'I have ssh key "ssh-rsa Work"' do - create(:key, user: @user, title: "ssh-rsa Work", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+L3TbFegm3k8QjejSwemk4HhlRh+DuN679Pc5ckqE/MPhVtE/+kZQDYCTB284GiT2aIoGzmZ8ee9TkaoejAsBwlA+Wz2Q3vhz65X6sMgalRwpdJx8kSEUYV8ZPV3MZvPo8KdNg993o4jL6G36GDW4BPIyO6FPZhfsawdf6liVD0Xo5kibIK7B9VoE178cdLQtLpS2YolRwf5yy6XR6hbbBGQR+6xrGOdP16eGZDb1CE2bMvvJijjloFqPscGktWOqW+nfh5txwFfBzlfARDTBsS8WZtg3Yoj1kn33kPsWRlgHfNutFRAIynDuDdQzQq8tTtVwm+Yi75RfcPHW8y3P Work") - end -end diff --git a/features/steps/project/badges/build.rb b/features/steps/project/badges/build.rb index 66a48a176e5..96c59322f9b 100644 --- a/features/steps/project/badges/build.rb +++ b/features/steps/project/badges/build.rb @@ -26,7 +26,7 @@ class Spinach::Features::ProjectBadgesBuild < Spinach::FeatureSteps def expect_badge(status) svg = Nokogiri::XML.parse(page.body) - expect(page.response_headers).to include('Content-Type' => 'image/svg+xml') + expect(page.response_headers['Content-Type']).to include('image/svg+xml') expect(svg.at(%Q{text:contains("#{status}")})).to be_truthy end end diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index b4a32ed2e38..055fca036d3 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -10,6 +10,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click artifacts browse button' do click_link 'Browse' + expect(page).not_to have_selector('.build-sidebar') end step 'I should see content of artifacts archive' do diff --git a/features/steps/project/commits/branches.rb b/features/steps/project/commits/branches.rb index 4bfb7e92e99..5f9b9e0445e 100644 --- a/features/steps/project/commits/branches.rb +++ b/features/steps/project/commits/branches.rb @@ -73,6 +73,11 @@ class Spinach::Features::ProjectCommitsBranches < Spinach::FeatureSteps expect(page).to have_content 'Branch already exists' end + step 'I filter for branch improve/awesome' do + fill_in 'branch-search', with: 'improve/awesome' + find('#branch-search').native.send_keys(:enter) + end + step "I click branch 'improve/awesome' delete link" do page.within '.js-branch-improve\/awesome' do find('.btn-remove').click diff --git a/features/steps/project/commits/commits.rb b/features/steps/project/commits/commits.rb index bea9f9d198b..b8264f97687 100644 --- a/features/steps/project/commits/commits.rb +++ b/features/steps/project/commits/commits.rb @@ -24,6 +24,14 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps expect(body).to have_selector("entry summary", text: commit.description[0..10]) end + step 'I click on tag link' do + click_link "Tag" + end + + step 'I see commit SHA pre-filled' do + expect(page).to have_selector("input[value='#{sample_commit.id}']") + end + step 'I click on commit link' do visit namespace_project_commit_path(@project.namespace, @project, sample_commit.id) end diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb index 8abeb5ee242..70dbd030003 100644 --- a/features/steps/project/fork.rb +++ b/features/steps/project/fork.rb @@ -70,6 +70,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps step 'There is an existent fork of the "Shop" project' do user = create(:user, name: 'Mike') + @project.team << [user, :reporter] @forked_project = Projects::ForkService.new(@project, user).execute end diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb index 6b56a77b832..6c14d835004 100644 --- a/features/steps/project/forked_merge_requests.rb +++ b/features/steps/project/forked_merge_requests.rb @@ -34,6 +34,9 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps end step 'I fill out a "Merge Request On Forked Project" merge request' do + expect(page).to have_content('Source branch') + expect(page).to have_content('Target branch') + first('.js-source-project').click first('.dropdown-source-project a', text: @forked_project.path_with_namespace) @@ -135,19 +138,19 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps end step 'I click "Assign to" dropdown"' do - first('.ajax-users-select').click + click_button 'Assignee' end step 'I should see the target project ID in the input selector' do - expect(page).to have_selector("input[data-project-id=\"#{@project.id}\"]") + expect(find('.js-assignee-search')["data-project-id"]).to eq "#{@project.id}" end step 'I should see the users from the target project ID' do - expect(page).to have_selector('.user-result', visible: true, count: 3) - users = page.all('.user-name') - expect(users[0].text).to eq 'Unassigned' - expect(users[1].text).to eq current_user.name - expect(users[2].text).to eq @project.users.first.name + page.within '.dropdown-menu-user' do + expect(page).to have_content 'Unassigned' + expect(page).to have_content current_user.name + expect(page).to have_content @project.users.first.name + end end # Verify a link is generated against the correct project diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index 1498f899cf5..cbe5738e7e4 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -48,7 +48,7 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps page.within '.awards' do expect(page).to have_selector '.js-emoji-btn' expect(page.find('.js-emoji-btn.active .js-counter')).to have_content '1' - expect(page).to have_css(".js-emoji-btn.active[data-original-title='me']") + expect(page).to have_css(".js-emoji-btn.active[data-original-title='You']") end end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 35f166c7c08..b50f5238e80 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -45,6 +45,8 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps step 'I click link "All"' do click_link "All" + # Waits for load + expect(find('.issues-state-filters > .active')).to have_content 'All' end step 'I click link "Release 0.4"' do @@ -82,7 +84,8 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps step 'I submit new issue "500 error on profile" with label \'bug\'' do fill_in "issue_title", with: "500 error on profile" - select 'bug', from: "Labels" + click_button "Label" + click_link "bug" click_button "Submit issue" end @@ -297,7 +300,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I fill in issue search with \'Rock and roll\'' do - filter_issue 'Description for issue' + filter_issue 'Rock and roll' end step 'I should see \'Bugfix1\' in issues' do @@ -354,6 +357,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end def filter_issue(text) - fill_in 'issue_search', with: text + fill_in 'issuable_search', with: text end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index da848afd48e..4a67cf06fba 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -22,6 +22,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click link "All"' do click_link "All" + # Waits for load + expect(find('.issues-state-filters > .active')).to have_content 'All' end step 'I click link "Merged"' do @@ -29,7 +31,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I click link "Closed"' do - click_link "Closed" + page.within('.issues-state-filters') do + click_link "Closed" + end end step 'I should see merge request "Wiki Feature"' do @@ -56,8 +60,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps expect(find('.merge-request-info')).not_to have_content "master" end - step 'I should see "other_branch" branch' do - expect(page).to have_content "other_branch" + step 'I should see "feature_conflict" branch' do + expect(page).to have_content "feature_conflict" end step 'I should see "Bug NS-04" in merge requests' do @@ -122,7 +126,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps source_project: project, target_project: project, source_branch: 'fix', - target_branch: 'other_branch', + target_branch: 'feature_conflict', author: project.users.first, description: "# Description header" ) @@ -477,6 +481,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click Side-by-side Diff tab' do find('a', text: 'Side-by-side').trigger('click') + + # Waits for load + expect(page).to have_css('.parallel') end step 'I should see comments on the side-by-side diff page' do @@ -486,10 +493,11 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I fill in merge request search with "Fe"' do - fill_in 'issue_search', with: "Fe" + fill_in 'issuable_search', with: "Fe" end step 'I click the "Target branch" dropdown' do + expect(page).to have_content('Target branch') first('.target_branch').click end diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb index 76fefee9254..975c879149e 100644 --- a/features/steps/project/project.rb +++ b/features/steps/project/project.rb @@ -5,7 +5,7 @@ class Spinach::Features::Project < Spinach::FeatureSteps step 'change project settings' do fill_in 'project_name_edit', with: 'NewName' - uncheck 'project_issues_enabled' + select 'Disabled', from: 'project_project_feature_attributes_issues_access_level' end step 'I save project' do diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb index beb8ecfc799..5e7d539add6 100644 --- a/features/steps/project/snippets.rb +++ b/features/steps/project/snippets.rb @@ -21,8 +21,8 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps author: project.users.first) end - step 'I click link "New Snippet"' do - click_link "New Snippet" + step 'I click link "New snippet"' do + click_link "New snippet" end step 'I click link "Snippet one"' do diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 9a8896acb15..1cc9e37b075 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -44,7 +44,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I should see its content with new lines preserved at end of file' do - expect(evaluate_script('blob.editor.getValue()')).to eq "Sample\n\n\n" + expect(evaluate_script('ace.edit("editor").getValue()')).to eq "Sample\n\n\n" end step 'I click link "Raw"' do @@ -65,15 +65,16 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps step 'I can edit code' do set_new_content - expect(evaluate_script('blob.editor.getValue()')).to eq new_gitignore_content + expect(evaluate_script('ace.edit("editor").getValue()')).to eq new_gitignore_content end step 'I edit code' do + expect(page).to have_selector('.file-editor') set_new_content end step 'I edit code with new lines at end of file' do - execute_script('blob.editor.setValue("Sample\n\n\n")') + execute_script('ace.edit("editor").setValue("Sample\n\n\n")') end step 'I fill the new file name' do @@ -104,6 +105,10 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps click_button 'Commit Changes' end + step 'I click on "Changes" tab' do + click_link 'Changes' + end + step 'I click on "Create directory"' do click_button 'Create directory' end @@ -131,6 +136,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps step 'I click on "New file" link in repo' do find('.add-to-tree').click click_link 'New file' + expect(page).to have_selector('.file-editor') end step 'I click on "Upload file" link in repo' do @@ -376,7 +382,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps private def set_new_content - execute_script("blob.editor.setValue('#{new_gitignore_content}')") + execute_script("ace.edit('editor').setValue('#{new_gitignore_content}')") end # Content of the gitignore file on the seed repository. diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index f32576d2cb1..e920f5a706b 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -65,8 +65,8 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps user = User.find_by(name: 'Dmitriy') project_member = project.project_members.find_by(user_id: user.id) page.within "#project_member_#{project_member.id}" do - click_button "Edit access level" - select "Reporter", from: "project_member_access_level" + click_button 'Edit' + select "Reporter", from: "member_access_level_#{project_member.id}" click_button "Save" end end diff --git a/features/steps/project/wiki.rb b/features/steps/project/wiki.rb index 732dc5d0b93..07a955b1a14 100644 --- a/features/steps/project/wiki.rb +++ b/features/steps/project/wiki.rb @@ -142,7 +142,9 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps end step 'I edit the Wiki page with a path' do + expect(page).to have_content('three') click_on 'three' + expect(find('.nav-text')).to have_content('Three') click_on 'Edit' end diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index 4d6b258f577..70e6d4836b2 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -10,20 +10,20 @@ module SharedBuilds end step 'project has a recent build' do - @pipeline = create(:ci_pipeline, project: @project, sha: @project.commit.sha, ref: 'master') + @pipeline = create(:ci_empty_pipeline, project: @project, sha: @project.commit.sha, ref: 'master') @build = create(:ci_build_with_coverage, pipeline: @pipeline) end step 'recent build is successful' do - @build.update(status: 'success') + @build.success end step 'recent build failed' do - @build.update(status: 'failed') + @build.drop end step 'project has another build that is running' do - create(:ci_build, pipeline: @pipeline, name: 'second build', status: 'running') + create(:ci_build, pipeline: @pipeline, name: 'second build', status_event: 'run') end step 'I visit recent build details page' do diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index b5fd24d246f..df9845ba569 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -133,9 +133,7 @@ module SharedIssuable end step 'The list should be sorted by "Oldest updated"' do - page.within('.content div.dropdown.inline.prepend-left-10') do - expect(page.find('button.dropdown-toggle.btn')).to have_content('Oldest updated') - end + expect(find('.issues-filters')).to have_content('Oldest updated') end step 'I click link "Next" in the sidebar' do @@ -181,7 +179,7 @@ module SharedIssuable project = Project.find_by(name: from_project_name) expect(page).to have_content(user_name) - expect(page).to have_content("mentioned in #{issuable.class.to_s.titleize.downcase} #{issuable.to_reference(project)}") + expect(page).to have_content("Mentioned in #{issuable.class.to_s.titleize.downcase} #{issuable.to_reference(project)}") end def expect_sidebar_content(content) diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index 0b4920883b8..afbd8ef1233 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -15,7 +15,7 @@ module SharedProject # Create a specific project called "Shop" step 'I own project "Shop"' do @project = Project.find_by(name: "Shop") - @project ||= create(:project, name: "Shop", namespace: @user.namespace, snippets_enabled: true) + @project ||= create(:project, name: "Shop", namespace: @user.namespace) @project.team << [@user, :master] end @@ -41,6 +41,8 @@ module SharedProject step 'I own project "Forum"' do @project = Project.find_by(name: "Forum") @project ||= create(:project, name: "Forum", namespace: @user.namespace, path: 'forum_project') + @project.build_project_feature + @project.project_feature.save @project.team << [@user, :master] end @@ -95,7 +97,7 @@ module SharedProject step 'I should see project settings' do expect(current_path).to eq edit_namespace_project_path(@project.namespace, @project) expect(page).to have_content("Project name") - expect(page).to have_content("Features") + expect(page).to have_content("Feature Visibility") end def current_project diff --git a/features/support/wait_for_ajax.rb b/features/support/wait_for_ajax.rb new file mode 100644 index 00000000000..b90fc112671 --- /dev/null +++ b/features/support/wait_for_ajax.rb @@ -0,0 +1,11 @@ +module WaitForAjax + def wait_for_ajax + Timeout.timeout(Capybara.default_max_wait_time) do + loop until finished_all_ajax_requests? + end + end + + def finished_all_ajax_requests? + page.evaluate_script('jQuery.active').zero? + end +end diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb new file mode 100644 index 00000000000..87915b19480 --- /dev/null +++ b/lib/api/access_requests.rb @@ -0,0 +1,72 @@ +module API + class AccessRequests < Grape::API + before { authenticate! } + + helpers ::API::Helpers::MembersHelpers + + %w[group project].each do |source_type| + params do + requires :id, type: String, desc: "The #{source_type} ID" + end + resource source_type.pluralize do + desc "Gets a list of access requests for a #{source_type}." do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::AccessRequester + end + get ":id/access_requests" do + source = find_source(source_type, params[:id]) + + access_requesters = AccessRequestsFinder.new(source).execute!(current_user) + access_requesters = paginate(access_requesters.includes(:user)) + + present access_requesters.map(&:user), with: Entities::AccessRequester, source: source + end + + desc "Requests access for the authenticated user to a #{source_type}." do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::AccessRequester + end + post ":id/access_requests" do + source = find_source(source_type, params[:id]) + access_requester = source.request_access(current_user) + + if access_requester.persisted? + present access_requester.user, with: Entities::AccessRequester, access_requester: access_requester + else + render_validation_error!(access_requester) + end + end + + desc 'Approves an access request for the given user.' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Member + end + params do + requires :user_id, type: Integer, desc: 'The user ID of the access requester' + optional :access_level, type: Integer, desc: 'A valid access level (defaults: `30`, developer access level)' + end + put ':id/access_requests/:user_id/approve' do + source = find_source(source_type, params[:id]) + + member = ::Members::ApproveAccessRequestService.new(source, current_user, declared(params)).execute + + status :created + present member.user, with: Entities::Member, member: member + end + + desc 'Denies an access request for the given user.' do + detail 'This feature was introduced in GitLab 8.11.' + end + params do + requires :user_id, type: Integer, desc: 'The user ID of the access requester' + end + delete ":id/access_requests/:user_id" do + source = find_source(source_type, params[:id]) + + ::Members::DestroyService.new(source, current_user, params). + execute(:requesters) + end + end + end + end +end diff --git a/lib/api/api.rb b/lib/api/api.rb index bd16806892b..67109ceeef9 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -3,56 +3,61 @@ module API include APIGuard version 'v3', using: :path + rescue_from Gitlab::Access::AccessDeniedError do + rack_response({ 'message' => '403 Forbidden' }.to_json, 403) + end + rescue_from ActiveRecord::RecordNotFound do rack_response({ 'message' => '404 Not found' }.to_json, 404) end - rescue_from Grape::Exceptions::ValidationErrors do |e| - error!({ messages: e.full_messages }, 400) + # Retain 405 error rather than a 500 error for Grape 0.15.0+. + # See: https://github.com/ruby-grape/grape/commit/252bfd27c320466ec3c0751812cf44245e97e5de + rescue_from Grape::Exceptions::Base do |e| + error! e.message, e.status, e.headers end rescue_from :all do |exception| - # lifted from https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 - # why is this not wrapped in something reusable? - trace = exception.backtrace - - message = "\n#{exception.class} (#{exception.message}):\n" - message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) - message << " " << trace.join("\n ") - - API.logger.add Logger::FATAL, message - rack_response({ 'message' => '500 Internal Server Error' }.to_json, 500) + handle_api_exception(exception) end format :json content_type :txt, "text/plain" # Ensure the namespace is right, otherwise we might load Grape::API::Helpers + helpers ::SentryHelper helpers ::API::Helpers + # Keep in alphabetical order + mount ::API::AccessRequests mount ::API::AwardEmoji + mount ::API::Boards mount ::API::Branches + mount ::API::BroadcastMessages mount ::API::Builds - mount ::API::CommitStatuses mount ::API::Commits + mount ::API::CommitStatuses mount ::API::DeployKeys + mount ::API::Deployments mount ::API::Environments mount ::API::Files - mount ::API::GroupMembers mount ::API::Groups mount ::API::Internal mount ::API::Issues mount ::API::Keys mount ::API::Labels - mount ::API::LicenseTemplates + mount ::API::Lint + mount ::API::Members + mount ::API::MergeRequestDiffs mount ::API::MergeRequests mount ::API::Milestones mount ::API::Namespaces mount ::API::Notes + mount ::API::NotificationSettings + mount ::API::Pipelines mount ::API::ProjectHooks - mount ::API::ProjectMembers - mount ::API::ProjectSnippets mount ::API::Projects + mount ::API::ProjectSnippets mount ::API::Repositories mount ::API::Runners mount ::API::Services @@ -67,5 +72,10 @@ module API mount ::API::Triggers mount ::API::Users mount ::API::Variables + mount ::API::Version + + route :any, '*path' do + error!('404 Not Found', 404) + end end end diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 7e67edb203a..8cc7a26f1fa 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -33,46 +33,29 @@ module API # # If the token is revoked, then it raises RevokedError. # - # If the token is not found (nil), then it raises TokenNotFoundError. + # If the token is not found (nil), then it returns nil # # Arguments: # # scopes: (optional) scopes required for this guard. # Defaults to empty array. # - def doorkeeper_guard!(scopes: []) - if (access_token = find_access_token).nil? - raise TokenNotFoundError - - else - case validate_access_token(access_token, scopes) - when Oauth2::AccessTokenValidationService::INSUFFICIENT_SCOPE - raise InsufficientScopeError.new(scopes) - when Oauth2::AccessTokenValidationService::EXPIRED - raise ExpiredError - when Oauth2::AccessTokenValidationService::REVOKED - raise RevokedError - when Oauth2::AccessTokenValidationService::VALID - @current_user = User.find(access_token.resource_owner_id) - end - end - end - def doorkeeper_guard(scopes: []) - if access_token = find_access_token - case validate_access_token(access_token, scopes) - when Oauth2::AccessTokenValidationService::INSUFFICIENT_SCOPE - raise InsufficientScopeError.new(scopes) + access_token = find_access_token + return nil unless access_token + + case validate_access_token(access_token, scopes) + when Oauth2::AccessTokenValidationService::INSUFFICIENT_SCOPE + raise InsufficientScopeError.new(scopes) - when Oauth2::AccessTokenValidationService::EXPIRED - raise ExpiredError + when Oauth2::AccessTokenValidationService::EXPIRED + raise ExpiredError - when Oauth2::AccessTokenValidationService::REVOKED - raise RevokedError + when Oauth2::AccessTokenValidationService::REVOKED + raise RevokedError - when Oauth2::AccessTokenValidationService::VALID - @current_user = User.find(access_token.resource_owner_id) - end + when Oauth2::AccessTokenValidationService::VALID + @current_user = User.find(access_token.resource_owner_id) end end @@ -96,19 +79,6 @@ module API end module ClassMethods - # Installs the doorkeeper guard on the whole Grape API endpoint. - # - # Arguments: - # - # scopes: (optional) scopes required for this guard. - # Defaults to empty array. - # - def guard_all!(scopes: []) - before do - guard! scopes: scopes - end - end - private def install_error_responders(base) diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb index 2efe7e3adf3..e9ccba3b465 100644 --- a/lib/api/award_emoji.rb +++ b/lib/api/award_emoji.rb @@ -1,23 +1,26 @@ module API class AwardEmoji < Grape::API before { authenticate! } - AWARDABLES = [Issue, MergeRequest] + AWARDABLES = %w[issue merge_request snippet] resource :projects do AWARDABLES.each do |awardable_type| - awardable_string = awardable_type.to_s.underscore.pluralize - awardable_id_string = "#{awardable_type.to_s.underscore}_id" + awardable_string = awardable_type.pluralize + awardable_id_string = "#{awardable_type}_id" + + params do + requires :id, type: String, desc: 'The ID of a project' + requires :"#{awardable_id_string}", type: Integer, desc: "The ID of an Issue, Merge Request or Snippet" + end [ ":id/#{awardable_string}/:#{awardable_id_string}/award_emoji", ":id/#{awardable_string}/:#{awardable_id_string}/notes/:note_id/award_emoji" ].each do |endpoint| - # Get a list of project +awardable+ award emoji - # - # Parameters: - # id (required) - The ID of a project - # awardable_id (required) - The ID of an issue or MR - # Example Request: - # GET /projects/:id/issues/:awardable_id/award_emoji + + desc 'Get a list of project +awardable+ award emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end get endpoint do if can_read_awardable? awards = paginate(awardable.award_emoji) @@ -27,14 +30,13 @@ module API end end - # Get a specific award emoji - # - # Parameters: - # id (required) - The ID of a project - # awardable_id (required) - The ID of an issue or MR - # award_id (required) - The ID of the award - # Example Request: - # GET /projects/:id/issues/:awardable_id/award_emoji/:award_id + desc 'Get a specific award emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end + params do + requires :award_id, type: Integer, desc: 'The ID of the award' + end get "#{endpoint}/:award_id" do if can_read_awardable? present awardable.award_emoji.find(params[:award_id]), with: Entities::AwardEmoji @@ -43,18 +45,15 @@ module API end end - # Award a new Emoji - # - # Parameters: - # id (required) - The ID of a project - # awardable_id (required) - The ID of an issue or mr - # name (required) - The name of a award_emoji (without colons) - # Example Request: - # POST /projects/:id/issues/:awardable_id/award_emoji + desc 'Award a new Emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end + params do + requires :name, type: String, desc: 'The name of a award_emoji (without colons)' + end post endpoint do - required_attributes! [:name] - - not_found!('Award Emoji') unless can_read_awardable? + not_found!('Award Emoji') unless can_read_awardable? && can_award_awardable? award = awardable.create_award_emoji(params[:name], current_user) @@ -65,14 +64,13 @@ module API end end - # Delete a +awardables+ award emoji - # - # Parameters: - # id (required) - The ID of a project - # awardable_id (required) - The ID of an issue or MR - # award_emoji_id (required) - The ID of an award emoji - # Example Request: - # DELETE /projects/:id/issues/:issue_id/notes/:note_id/award_emoji/:award_id + desc 'Delete a +awardables+ award emoji' do + detail 'This feature was introduced in 8.9' + success Entities::AwardEmoji + end + params do + requires :award_id, type: Integer, desc: 'The ID of an award emoji' + end delete "#{endpoint}/:award_id" do award = awardable.award_emoji.find(params[:award_id]) @@ -87,27 +85,36 @@ module API helpers do def can_read_awardable? - ability = "read_#{awardable.class.to_s.underscore}".to_sym + can?(current_user, read_ability(awardable), awardable) + end - can?(current_user, ability, awardable) + def can_award_awardable? + awardable.user_can_award?(current_user, params[:name]) end def awardable @awardable ||= begin if params.include?(:note_id) - noteable.notes.find(params[:note_id]) + note_id = params.delete(:note_id) + + awardable.notes.find(note_id) + elsif params.include?(:issue_id) + user_project.issues.find(params[:issue_id]) + elsif params.include?(:merge_request_id) + user_project.merge_requests.find(params[:merge_request_id]) else - noteable + user_project.snippets.find(params[:snippet_id]) end end end - def noteable - if params.include?(:issue_id) - user_project.issues.find(params[:issue_id]) + def read_ability(awardable) + case awardable + when Note + read_ability(awardable.noteable) else - user_project.merge_requests.find(params[:merge_request_id]) + :"read_#{awardable.class.to_s.underscore}" end end end diff --git a/lib/api/boards.rb b/lib/api/boards.rb new file mode 100644 index 00000000000..b14dd4f6e83 --- /dev/null +++ b/lib/api/boards.rb @@ -0,0 +1,132 @@ +module API + # Boards API + class Boards < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects do + desc 'Get all project boards' do + detail 'This feature was introduced in 8.13' + success Entities::Board + end + get ':id/boards' do + authorize!(:read_board, user_project) + present user_project.boards, with: Entities::Board + end + + params do + requires :board_id, type: Integer, desc: 'The ID of a board' + end + segment ':id/boards/:board_id' do + helpers do + def project_board + board = user_project.boards.first + + if params[:board_id] == board.id + board + else + not_found!('Board') + end + end + + def board_lists + project_board.lists.destroyable + end + end + + desc 'Get the lists of a project board' do + detail 'Does not include `backlog` and `done` lists. This feature was introduced in 8.13' + success Entities::List + end + get '/lists' do + authorize!(:read_board, user_project) + present board_lists, with: Entities::List + end + + desc 'Get a list of a project board' do + detail 'This feature was introduced in 8.13' + success Entities::List + end + params do + requires :list_id, type: Integer, desc: 'The ID of a list' + end + get '/lists/:list_id' do + authorize!(:read_board, user_project) + present board_lists.find(params[:list_id]), with: Entities::List + end + + desc 'Create a new board list' do + detail 'This feature was introduced in 8.13' + success Entities::List + end + params do + requires :label_id, type: Integer, desc: 'The ID of an existing label' + end + post '/lists' do + unless user_project.labels.exists?(params[:label_id]) + render_api_error!({ error: "Label not found!" }, 400) + end + + authorize!(:admin_list, user_project) + + service = ::Boards::Lists::CreateService.new(user_project, current_user, + { label_id: params[:label_id] }) + + list = service.execute(project_board) + + if list.valid? + present list, with: Entities::List + else + render_validation_error!(list) + end + end + + desc 'Moves a board list to a new position' do + detail 'This feature was introduced in 8.13' + success Entities::List + end + params do + requires :list_id, type: Integer, desc: 'The ID of a list' + requires :position, type: Integer, desc: 'The position of the list' + end + put '/lists/:list_id' do + list = project_board.lists.movable.find(params[:list_id]) + + authorize!(:admin_list, user_project) + + service = ::Boards::Lists::MoveService.new(user_project, current_user, + { position: params[:position] }) + + if service.execute(list) + present list, with: Entities::List + else + render_api_error!({ error: "List could not be moved!" }, 400) + end + end + + desc 'Delete a board list' do + detail 'This feature was introduced in 8.13' + success Entities::List + end + params do + requires :list_id, type: Integer, desc: 'The ID of a board list' + end + delete "/lists/:list_id" do + authorize!(:admin_list, user_project) + + list = board_lists.find(params[:list_id]) + + service = ::Boards::Lists::DestroyService.new(user_project, current_user) + + if service.execute(list) + present list, with: Entities::List + else + render_api_error!({ error: 'List could not be deleted!' }, 400) + end + end + end + end + end +end diff --git a/lib/api/branches.rb b/lib/api/branches.rb index a77afe634f6..b615703df93 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -61,22 +61,27 @@ module API name: @branch.name } - unless developers_can_merge.nil? - protected_branch_params.merge!({ - merge_access_level_attributes: { - access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } - }) + # If `developers_can_merge` is switched off, _all_ `DEVELOPER` + # merge_access_levels need to be deleted. + if developers_can_merge == false + protected_branch.merge_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all end - unless developers_can_push.nil? - protected_branch_params.merge!({ - push_access_level_attributes: { - access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } - }) + # If `developers_can_push` is switched off, _all_ `DEVELOPER` + # push_access_levels need to be deleted. + if developers_can_push == false + protected_branch.push_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all end + protected_branch_params.merge!( + merge_access_levels_attributes: [{ + access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }], + push_access_levels_attributes: [{ + access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }] + ) + if protected_branch service = ProtectedBranches::UpdateService.new(user_project, current_user, protected_branch_params) service.execute(protected_branch) diff --git a/lib/api/broadcast_messages.rb b/lib/api/broadcast_messages.rb new file mode 100644 index 00000000000..fb2a4148011 --- /dev/null +++ b/lib/api/broadcast_messages.rb @@ -0,0 +1,99 @@ +module API + class BroadcastMessages < Grape::API + before { authenticate! } + before { authenticated_as_admin! } + + resource :broadcast_messages do + helpers do + def find_message + BroadcastMessage.find(params[:id]) + end + end + + desc 'Get all broadcast messages' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::BroadcastMessage + end + params do + optional :page, type: Integer, desc: 'Current page number' + optional :per_page, type: Integer, desc: 'Number of messages per page' + end + get do + messages = BroadcastMessage.all + + present paginate(messages), with: Entities::BroadcastMessage + end + + desc 'Create a broadcast message' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::BroadcastMessage + end + params do + requires :message, type: String, desc: 'Message to display' + optional :starts_at, type: DateTime, desc: 'Starting time', default: -> { Time.zone.now } + optional :ends_at, type: DateTime, desc: 'Ending time', default: -> { 1.hour.from_now } + optional :color, type: String, desc: 'Background color' + optional :font, type: String, desc: 'Foreground color' + end + post do + create_params = declared(params, include_missing: false).to_h + message = BroadcastMessage.create(create_params) + + if message.persisted? + present message, with: Entities::BroadcastMessage + else + render_validation_error!(message) + end + end + + desc 'Get a specific broadcast message' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::BroadcastMessage + end + params do + requires :id, type: Integer, desc: 'Broadcast message ID' + end + get ':id' do + message = find_message + + present message, with: Entities::BroadcastMessage + end + + desc 'Update a broadcast message' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::BroadcastMessage + end + params do + requires :id, type: Integer, desc: 'Broadcast message ID' + optional :message, type: String, desc: 'Message to display' + optional :starts_at, type: DateTime, desc: 'Starting time' + optional :ends_at, type: DateTime, desc: 'Ending time' + optional :color, type: String, desc: 'Background color' + optional :font, type: String, desc: 'Foreground color' + end + put ':id' do + message = find_message + update_params = declared(params, include_missing: false).to_h + + if message.update(update_params) + present message, with: Entities::BroadcastMessage + else + render_validation_error!(message) + end + end + + desc 'Delete a broadcast message' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::BroadcastMessage + end + params do + requires :id, type: Integer, desc: 'Broadcast message ID' + end + delete ':id' do + message = find_message + + present message.destroy, with: Entities::BroadcastMessage + end + end + end +end diff --git a/lib/api/builds.rb b/lib/api/builds.rb index be5a3484ec8..52bdbcae5a8 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -189,6 +189,27 @@ module API present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :read_build, user_project) end + + desc 'Trigger a manual build' do + success Entities::Build + detail 'This feature was added in GitLab 8.11' + end + params do + requires :build_id, type: Integer, desc: 'The ID of a Build' + end + post ":id/builds/:build_id/play" do + authorize_read_builds! + + build = get_build!(params[:build_id]) + + bad_request!("Unplayable Build") unless build.playable? + + build.play(current_user) + + status 200 + present build, with: Entities::Build, + user_can_download_artifacts: can?(current_user, :read_build, user_project) + end end helpers do diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index 4df6ca8333e..dfbdd597d29 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -37,7 +37,7 @@ module API # id (required) - The ID of a project # sha (required) - The commit hash # ref (optional) - The ref - # state (required) - The state of the status. Can be: pending, running, success, error or failure + # state (required) - The state of the status. Can be: pending, running, success, failed or canceled # target_url (optional) - The target URL to associate with this status # description (optional) - A short description of the status # name or context (optional) - A string label to differentiate this status from the status of other systems. Default: "default" @@ -46,7 +46,7 @@ module API post ':id/statuses/:sha' do authorize! :create_commit_status, user_project required_attributes! [:state] - attrs = attributes_for_keys [:ref, :target_url, :description, :context, :name] + attrs = attributes_for_keys [:target_url, :description] commit = @project.commit(params[:sha]) not_found! 'Commit' unless commit @@ -58,36 +58,38 @@ module API # the first found branch on that commit ref = params[:ref] - unless ref - branches = @project.repository.branch_names_contains(commit.sha) - not_found! 'References for commit' if branches.none? - ref = branches.first - end + ref ||= @project.repository.branch_names_contains(commit.sha).first + not_found! 'References for commit' unless ref - pipeline = @project.ensure_pipeline(commit.sha, ref, current_user) + name = params[:name] || params[:context] || 'default' - name = params[:name] || params[:context] - status = GenericCommitStatus.running_or_pending.find_by(pipeline: pipeline, name: name, ref: params[:ref]) - status ||= GenericCommitStatus.new(project: @project, pipeline: pipeline, user: current_user) - status.update(attrs) + pipeline = @project.ensure_pipeline(ref, commit.sha, current_user) - case params[:state].to_s - when 'running' - status.run - when 'success' - status.success - when 'failed' - status.drop - when 'canceled' - status.cancel - else - status.status = params[:state].to_s - end + status = GenericCommitStatus.running_or_pending.find_or_initialize_by( + project: @project, pipeline: pipeline, + user: current_user, name: name, ref: ref) + status.attributes = attrs + + begin + case params[:state].to_s + when 'pending' + status.enqueue! + when 'running' + status.enqueue + status.run! + when 'success' + status.success! + when 'failed' + status.drop! + when 'canceled' + status.cancel! + else + render_api_error!('invalid state', 400) + end - if status.save present status, with: Entities::CommitStatus - else - render_validation_error!(status) + rescue StateMachines::InvalidTransition => e + render_api_error!(e.message, 400) end end end diff --git a/lib/api/commits.rb b/lib/api/commits.rb index b4eaf1813d4..14ddc8c9a62 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -29,6 +29,42 @@ module API present commits, with: Entities::RepoCommit end + desc 'Commit multiple file changes as one commit' do + detail 'This feature was introduced in GitLab 8.13' + end + + params do + requires :id, type: Integer, desc: 'The project ID' + requires :branch_name, type: String, desc: 'The name of branch' + requires :commit_message, type: String, desc: 'Commit message' + requires :actions, type: Array, desc: 'Actions to perform in commit' + optional :author_email, type: String, desc: 'Author email for commit' + optional :author_name, type: String, desc: 'Author name for commit' + end + + post ":id/repository/commits" do + authorize! :push_code, user_project + + attrs = declared(params) + attrs[:source_branch] = attrs[:branch_name] + attrs[:target_branch] = attrs[:branch_name] + attrs[:actions].map! do |action| + action[:action] = action[:action].to_sym + action[:file_path].slice!(0) if action[:file_path] && action[:file_path].start_with?('/') + action[:previous_path].slice!(0) if action[:previous_path] && action[:previous_path].start_with?('/') + action + end + + result = ::Files::MultiService.new(user_project, current_user, attrs).execute + + if result[:status] == :success + commit_detail = user_project.repository.commits(result[:result], limit: 1).first + present commit_detail, with: Entities::RepoCommitDetail + else + render_api_error!(result[:message], 400) + end + end + # Get a specific commit of a project # # Parameters: diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb index 5c570b5e5ca..825e05fbae3 100644 --- a/lib/api/deploy_keys.rb +++ b/lib/api/deploy_keys.rb @@ -10,6 +10,9 @@ module API present keys, with: Entities::SSHKey end + params do + requires :id, type: String, desc: 'The ID of the project' + end resource :projects do before { authorize_admin_project } @@ -17,52 +20,43 @@ module API # Use "projects/:id/deploy_keys/..." instead. # %w(keys deploy_keys).each do |path| - # Get a specific project's deploy keys - # - # Example Request: - # GET /projects/:id/deploy_keys + desc "Get a specific project's deploy keys" do + success Entities::SSHKey + end get ":id/#{path}" do present user_project.deploy_keys, with: Entities::SSHKey end - # Get single deploy key owned by currently authenticated user - # - # Example Request: - # GET /projects/:id/deploy_keys/:key_id + desc 'Get single deploy key' do + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end get ":id/#{path}/:key_id" do key = user_project.deploy_keys.find params[:key_id] present key, with: Entities::SSHKey end - # Add new deploy key to currently authenticated user - # If deploy key already exists - it will be joined to project - # but only if original one was accessible by same user - # - # Parameters: - # key (required) - New deploy Key - # title (required) - New deploy Key's title - # Example Request: - # POST /projects/:id/deploy_keys + # TODO: for 9.0 we should check if params are there with the params block + # grape provides, at this point we'd change behaviour so we can't + # Behaviour now if you don't provide all required params: it renders a + # validation error or two. + desc 'Add new deploy key to currently authenticated user' do + success Entities::SSHKey + end post ":id/#{path}" do attrs = attributes_for_keys [:title, :key] + attrs[:key].strip! if attrs[:key] - if attrs[:key].present? - attrs[:key].strip! - - # check if key already exist in project - key = user_project.deploy_keys.find_by(key: attrs[:key]) - if key - present key, with: Entities::SSHKey - next - end + key = user_project.deploy_keys.find_by(key: attrs[:key]) + present key, with: Entities::SSHKey if key - # Check for available deploy keys in other projects - key = current_user.accessible_deploy_keys.find_by(key: attrs[:key]) - if key - user_project.deploy_keys << key - present key, with: Entities::SSHKey - next - end + # Check for available deploy keys in other projects + key = current_user.accessible_deploy_keys.find_by(key: attrs[:key]) + if key + user_project.deploy_keys << key + present key, with: Entities::SSHKey end key = DeployKey.new attrs @@ -74,12 +68,46 @@ module API end end - # Delete existing deploy key of currently authenticated user - # - # Example Request: - # DELETE /projects/:id/deploy_keys/:key_id + desc 'Enable a deploy key for a project' do + detail 'This feature was added in GitLab 8.11' + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end + post ":id/#{path}/:key_id/enable" do + key = ::Projects::EnableDeployKeyService.new(user_project, + current_user, declared(params)).execute + + if key + present key, with: Entities::SSHKey + else + not_found!('Deploy Key') + end + end + + desc 'Disable a deploy key for a project' do + detail 'This feature was added in GitLab 8.11' + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end + delete ":id/#{path}/:key_id/disable" do + key = user_project.deploy_keys_projects.find_by(deploy_key_id: params[:key_id]) + key.destroy + + present key.deploy_key, with: Entities::SSHKey + end + + desc 'Delete existing deploy key of currently authenticated user' do + success Key + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end delete ":id/#{path}/:key_id" do - key = user_project.deploy_keys.find params[:key_id] + key = user_project.deploy_keys.find(params[:key_id]) key.destroy end end diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb new file mode 100644 index 00000000000..f782bcaf7e9 --- /dev/null +++ b/lib/api/deployments.rb @@ -0,0 +1,40 @@ +module API + # Deployments RESTfull API endpoints + class Deployments < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects do + desc 'Get all deployments of the project' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Deployment + end + params do + optional :page, type: Integer, desc: 'Page number of the current request' + optional :per_page, type: Integer, desc: 'Number of items per page' + end + get ':id/deployments' do + authorize! :read_deployment, user_project + + present paginate(user_project.deployments), with: Entities::Deployment + end + + desc 'Gets a specific deployment' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Deployment + end + params do + requires :deployment_id, type: Integer, desc: 'The deployment ID' + end + get ':id/deployments/:deployment_id' do + authorize! :read_deployment, user_project + + deployment = user_project.deployments.find(params[:deployment_id]) + + present deployment, with: Entities::Deployment + end + end + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index f8bb7d215ee..c471a45dd3b 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -15,7 +15,7 @@ module API class User < UserBasic expose :created_at expose :is_admin?, as: :is_admin - expose :bio, :location, :skype, :linkedin, :twitter, :website_url + expose :bio, :location, :skype, :linkedin, :twitter, :website_url, :organization end class Identity < Grape::Entity @@ -48,7 +48,8 @@ module API class ProjectHook < Hook expose :project_id, :push_events - expose :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events + expose :issues_events, :merge_requests_events, :tag_push_events + expose :note_events, :build_events, :pipeline_events, :wiki_page_events expose :enable_ssl_verification end @@ -75,32 +76,57 @@ module API expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group } expose :name, :name_with_namespace expose :path, :path_with_namespace - expose :issues_enabled, :merge_requests_enabled, :wiki_enabled, :builds_enabled, :snippets_enabled, :container_registry_enabled + expose :container_registry_enabled + + # Expose old field names with the new permissions methods to keep API compatible + expose(:issues_enabled) { |project, options| project.feature_available?(:issues, options[:user]) } + expose(:merge_requests_enabled) { |project, options| project.feature_available?(:merge_requests, options[:user]) } + expose(:wiki_enabled) { |project, options| project.feature_available?(:wiki, options[:user]) } + expose(:builds_enabled) { |project, options| project.feature_available?(:builds, options[:user]) } + expose(:snippets_enabled) { |project, options| project.feature_available?(:snippets, options[:user]) } + expose :created_at, :last_activity_at expose :shared_runners_enabled + expose :lfs_enabled?, as: :lfs_enabled expose :creator_id expose :namespace expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } expose :avatar_url expose :star_count, :forks_count - expose :open_issues_count, if: lambda { |project, options| project.issues_enabled? && project.default_issues_tracker? } + expose :open_issues_count, if: lambda { |project, options| project.feature_available?(:issues, options[:user]) && project.default_issues_tracker? } expose :runners_token, if: lambda { |_project, options| options[:user_can_admin_project] } expose :public_builds expose :shared_with_groups do |project, options| SharedGroup.represent(project.project_group_links.all, options) end + expose :only_allow_merge_if_build_succeeds + expose :request_access_enabled end - class ProjectMember < UserBasic + class Member < UserBasic expose :access_level do |user, options| - options[:project].project_members.find_by(user_id: user.id).access_level + member = options[:member] || options[:source].members.find_by(user_id: user.id) + member.access_level + end + expose :expires_at do |user, options| + member = options[:member] || options[:source].members.find_by(user_id: user.id) + member.expires_at + end + end + + class AccessRequester < UserBasic + expose :requested_at do |user, options| + access_requester = options[:access_requester] || options[:source].requesters.find_by(user_id: user.id) + access_requester.requested_at end end class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level + expose :lfs_enabled?, as: :lfs_enabled expose :avatar_url expose :web_url + expose :request_access_enabled end class GroupDetail < Group @@ -108,12 +134,6 @@ module API expose :shared_projects, using: Entities::Project end - class GroupMember < UserBasic - expose :access_level do |user, options| - options[:group].group_members.find_by(user_id: user.id).access_level - end - end - class RepoBranch < Grape::Entity expose :name @@ -127,12 +147,14 @@ module API expose :developers_can_push do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_level.access_level == Gitlab::Access::DEVELOPER } + access_levels = project.protected_branches.matching(repo_branch.name).map(&:push_access_levels).flatten + access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } end expose :developers_can_merge do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_level.access_level == Gitlab::Access::DEVELOPER } + access_levels = project.protected_branches.matching(repo_branch.name).map(&:merge_access_levels).flatten + access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } end end @@ -168,6 +190,10 @@ module API # TODO (rspeicher): Deprecated; remove in 9.0 expose(:expires_at) { |snippet| nil } + + expose :web_url do |snippet, options| + Gitlab::UrlBuilder.build(snippet) + end end class ProjectEntity < Grape::Entity @@ -197,6 +223,11 @@ module API expose :user_notes_count expose :upvotes, :downvotes expose :due_date + expose :confidential + + expose :web_url do |issue, options| + Gitlab::UrlBuilder.build(issue) + end end class ExternalIssue < Grape::Entity @@ -214,12 +245,18 @@ module API expose :milestone, using: Entities::Milestone expose :merge_when_build_succeeds expose :merge_status + expose :diff_head_sha, as: :sha + expose :merge_commit_sha expose :subscribed do |merge_request, options| merge_request.subscribed?(options[:current_user]) end expose :user_notes_count expose(:should_remove_source_branch?) { |mr| mr.remove_source_branch } expose(:force_remove_source_branch?) { |mr| mr.remove_source_branch } + + expose :web_url do |merge_request, options| + Gitlab::UrlBuilder.build(merge_request) + end end class MergeRequestChanges < MergeRequest @@ -228,6 +265,19 @@ module API end end + class MergeRequestDiff < Grape::Entity + expose :id, :head_commit_sha, :base_commit_sha, :start_commit_sha, + :created_at, :merge_request_id, :state, :real_size + end + + class MergeRequestDiffFull < MergeRequestDiff + expose :commits, using: Entities::RepoCommit + + expose :diffs, using: Entities::RepoDiff do |compare, _| + compare.raw_diffs(all_diffs: true).to_a + end + end + class SSHKey < Grape::Entity expose :id, :title, :key, :created_at end @@ -293,7 +343,7 @@ module API end class ProjectGroupLink < Grape::Entity - expose :id, :project_id, :group_id, :group_access + expose :id, :project_id, :group_id, :group_access, :expires_at end class Todo < Grape::Entity @@ -325,24 +375,40 @@ module API expose :id, :path, :kind end - class Member < Grape::Entity + class MemberAccess < Grape::Entity expose :access_level expose :notification_level do |member, options| if member.notification_setting - NotificationSetting.levels[member.notification_setting.level] + ::NotificationSetting.levels[member.notification_setting.level] end end end - class ProjectAccess < Member + class ProjectAccess < MemberAccess end - class GroupAccess < Member + class GroupAccess < MemberAccess + end + + class NotificationSetting < Grape::Entity + expose :level + expose :events, if: ->(notification_setting, _) { notification_setting.custom? } do + ::NotificationSetting::EMAIL_EVENTS.each do |event| + expose event + end + end + end + + class GlobalNotificationSetting < NotificationSetting + expose :notification_email do |notification_setting, options| + notification_setting.user.notification_email + end end class ProjectService < Grape::Entity expose :id, :title, :created_at, :updated_at, :active - expose :push_events, :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events + expose :push_events, :issues_events, :merge_requests_events + expose :tag_push_events, :note_events, :build_events, :pipeline_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. @@ -366,8 +432,11 @@ module API end end - class Label < Grape::Entity + class LabelBasic < Grape::Entity expose :name, :color, :description + end + + class Label < LabelBasic expose :open_issues_count, :closed_issues_count, :open_merge_requests_count expose :subscribed do |label, options| @@ -375,6 +444,19 @@ module API end end + class List < Grape::Entity + expose :id + expose :label, using: Entities::LabelBasic + expose :position + end + + class Board < Grape::Entity + expose :id + expose :lists, using: Entities::List do |board| + board.lists.destroyable + end + end + class Compare < Grape::Entity expose :commit, using: Entities::RepoCommit do |compare, options| Commit.decorate(compare.commits, nil).last @@ -428,6 +510,8 @@ module API expose :after_sign_out_path expose :container_registry_token_expire_delay expose :repository_storage + expose :koding_enabled + expose :koding_url end class Release < Grape::Entity @@ -479,6 +563,10 @@ module API expose :filename, :size end + class PipelineBasic < Grape::Entity + expose :id, :sha, :ref, :status + end + class Build < Grape::Entity expose :id, :status, :stage, :name, :ref, :tag, :coverage expose :created_at, :started_at, :finished_at @@ -486,6 +574,7 @@ module API expose :artifacts_file, using: BuildArtifactFile, if: -> (build, opts) { build.artifacts? } expose :commit, with: RepoCommit expose :runner, with: Runner + expose :pipeline, with: PipelineBasic end class Trigger < Grape::Entity @@ -496,10 +585,29 @@ module API expose :key, :value end - class Environment < Grape::Entity + class Pipeline < PipelineBasic + expose :before_sha, :tag, :yaml_errors + + expose :user, with: Entities::UserBasic + expose :created_at, :updated_at, :started_at, :finished_at, :committed_at + expose :duration + end + + class EnvironmentBasic < Grape::Entity expose :id, :name, :external_url end + class Environment < EnvironmentBasic + expose :project, using: Entities::Project + end + + class Deployment < Grape::Entity + expose :id, :iid, :ref, :sha, :created_at + expose :user, using: Entities::UserBasic + expose :environment, using: Entities::EnvironmentBasic + expose :deployable, using: Entities::Build + end + class RepoLicense < Grape::Entity expose :key, :name, :nickname expose :featured, as: :popular @@ -519,5 +627,10 @@ module API class Template < Grape::Entity expose :name, :content end + + class BroadcastMessage < Grape::Entity + expose :id, :message, :starts_at, :ends_at, :color, :font + expose :active?, as: :active + end end end diff --git a/lib/api/files.rb b/lib/api/files.rb index c1d86f313b0..96510e651a3 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -11,14 +11,16 @@ module API target_branch: attrs[:branch_name], commit_message: attrs[:commit_message], file_content: attrs[:content], - file_content_encoding: attrs[:encoding] + file_content_encoding: attrs[:encoding], + author_email: attrs[:author_email], + author_name: attrs[:author_name] } end def commit_response(attrs) { file_path: attrs[:file_path], - branch_name: attrs[:branch_name], + branch_name: attrs[:branch_name] } end end @@ -96,7 +98,7 @@ module API authorize! :push_code, user_project required_attributes! [:file_path, :branch_name, :content, :commit_message] - attrs = attributes_for_keys [:file_path, :branch_name, :content, :commit_message, :encoding] + attrs = attributes_for_keys [:file_path, :branch_name, :content, :commit_message, :encoding, :author_email, :author_name] result = ::Files::CreateService.new(user_project, current_user, commit_params(attrs)).execute if result[:status] == :success @@ -122,7 +124,7 @@ module API authorize! :push_code, user_project required_attributes! [:file_path, :branch_name, :content, :commit_message] - attrs = attributes_for_keys [:file_path, :branch_name, :content, :commit_message, :encoding] + attrs = attributes_for_keys [:file_path, :branch_name, :content, :commit_message, :encoding, :author_email, :author_name] result = ::Files::UpdateService.new(user_project, current_user, commit_params(attrs)).execute if result[:status] == :success @@ -149,7 +151,7 @@ module API authorize! :push_code, user_project required_attributes! [:file_path, :branch_name, :commit_message] - attrs = attributes_for_keys [:file_path, :branch_name, :commit_message] + attrs = attributes_for_keys [:file_path, :branch_name, :commit_message, :author_email, :author_name] result = ::Files::DeleteService.new(user_project, current_user, commit_params(attrs)).execute if result[:status] == :success diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb deleted file mode 100644 index dbe5bb08d3f..00000000000 --- a/lib/api/group_members.rb +++ /dev/null @@ -1,87 +0,0 @@ -module API - class GroupMembers < Grape::API - before { authenticate! } - - resource :groups do - # Get a list of group members viewable by the authenticated user. - # - # Example Request: - # GET /groups/:id/members - get ":id/members" do - group = find_group(params[:id]) - users = group.users - present users, with: Entities::GroupMember, group: group - end - - # Add a user to the list of group members - # - # Parameters: - # id (required) - group id - # user_id (required) - the users id - # access_level (required) - Project access level - # Example Request: - # POST /groups/:id/members - post ":id/members" do - group = find_group(params[:id]) - authorize! :admin_group, group - required_attributes! [:user_id, :access_level] - - unless validate_access_level?(params[:access_level]) - render_api_error!("Wrong access level", 422) - end - - if group.group_members.find_by(user_id: params[:user_id]) - render_api_error!("Already exists", 409) - end - - group.add_users([params[:user_id]], params[:access_level], current_user) - member = group.group_members.find_by(user_id: params[:user_id]) - present member.user, with: Entities::GroupMember, group: group - end - - # Update group member - # - # Parameters: - # id (required) - The ID of a group - # user_id (required) - The ID of a group member - # access_level (required) - Project access level - # Example Request: - # PUT /groups/:id/members/:user_id - put ':id/members/:user_id' do - group = find_group(params[:id]) - authorize! :admin_group, group - required_attributes! [:access_level] - - group_member = group.group_members.find_by(user_id: params[:user_id]) - not_found!('User can not be found') if group_member.nil? - - if group_member.update_attributes(access_level: params[:access_level]) - @member = group_member.user - present @member, with: Entities::GroupMember, group: group - else - handle_member_errors group_member.errors - end - end - - # Remove member. - # - # Parameters: - # id (required) - group id - # user_id (required) - the users id - # - # Example Request: - # DELETE /groups/:id/members/:user_id - delete ":id/members/:user_id" do - group = find_group(params[:id]) - authorize! :admin_group, group - member = group.group_members.find_by(user_id: params[:user_id]) - - if member.nil? - render_api_error!("404 Not Found - user_id:#{params[:user_id]} not a member of group #{group.name}", 404) - else - member.destroy - end - end - end - end -end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 9d8b8d737a9..bfb89475025 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -6,6 +6,8 @@ module API resource :groups do # Get a groups list # + # Parameters: + # skip_groups (optional) - Array of group ids to exclude from list # Example Request: # GET /groups get do @@ -16,6 +18,7 @@ module API end @groups = @groups.search(params[:search]) if params[:search].present? + @groups = @groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? @groups = paginate @groups present @groups, with: Entities::Group end @@ -23,17 +26,19 @@ module API # Create group. Available only for users who can create groups. # # Parameters: - # name (required) - The name of the group - # path (required) - The path of the group - # description (optional) - The description of the group - # visibility_level (optional) - The visibility level of the group + # name (required) - The name of the group + # path (required) - The path of the group + # description (optional) - The description of the group + # visibility_level (optional) - The visibility level of the group + # lfs_enabled (optional) - Enable/disable LFS for the projects in this group + # request_access_enabled (optional) - Allow users to request member access # Example Request: # POST /groups post do - authorize! :create_group, current_user + authorize! :create_group required_attributes! [:name, :path] - attrs = attributes_for_keys [:name, :path, :description, :visibility_level] + attrs = attributes_for_keys [:name, :path, :description, :visibility_level, :lfs_enabled, :request_access_enabled] @group = Group.new(attrs) if @group.save @@ -47,17 +52,19 @@ module API # Update group. Available only for users who can administrate groups. # # Parameters: - # id (required) - The ID of a group - # path (optional) - The path of the group - # description (optional) - The description of the group - # visibility_level (optional) - The visibility level of the group + # id (required) - The ID of a group + # path (optional) - The path of the group + # description (optional) - The description of the group + # visibility_level (optional) - The visibility level of the group + # lfs_enabled (optional) - Enable/disable LFS for the projects in this group + # request_access_enabled (optional) - Allow users to request member access # Example Request: # PUT /groups/:id put ':id' do group = find_group(params[:id]) authorize! :admin_group, group - attrs = attributes_for_keys [:name, :path, :description, :visibility_level] + attrs = attributes_for_keys [:name, :path, :description, :visibility_level, :lfs_enabled, :request_access_enabled] if ::Groups::UpdateService.new(group, current_user, attrs).execute present group, with: Entities::GroupDetail @@ -97,7 +104,7 @@ module API group = find_group(params[:id]) projects = GroupProjectsFinder.new(group).execute(current_user) projects = paginate projects - present projects, with: Entities::Project + present projects, with: Entities::Project, user: current_user end # Transfer a project to the Group namespace diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 130509cdad6..67473f300c9 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -12,13 +12,33 @@ module API nil end + def private_token + params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER] + end + + def warden + env['warden'] + end + + # Check the Rails session for valid authentication details + # + # Until CSRF protection is added to the API, disallow this method for + # state-changing endpoints + def find_user_from_warden + warden.try(:authenticate) if %w[GET HEAD].include?(env['REQUEST_METHOD']) + end + def find_user_by_private_token - token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s - User.find_by_authentication_token(token_string) || User.find_by_personal_access_token(token_string) + token = private_token + return nil unless token.present? + + User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) end def current_user - @current_user ||= (find_user_by_private_token || doorkeeper_guard) + @current_user ||= find_user_by_private_token + @current_user ||= doorkeeper_guard + @current_user ||= find_user_from_warden unless @current_user && Gitlab::UserAccess.new(@current_user).allowed? return nil @@ -28,7 +48,7 @@ module API # If the sudo is the current user do nothing if identifier && !(@current_user.id == identifier || @current_user.username == identifier) - render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin? + forbidden!('Must be admin to use sudo') unless @current_user.is_admin? @current_user = User.by_username_or_id(identifier) not_found!("No user id or username for: #{identifier}") if @current_user.nil? end @@ -49,16 +69,15 @@ module API def user_project @project ||= find_project(params[:id]) - @project || not_found!("Project") end def find_project(id) project = Project.find_with_namespace(id) || Project.find_by(id: id) - if project && can?(current_user, :read_project, project) + if can?(current_user, :read_project, project) project else - nil + not_found!('Project') end end @@ -89,11 +108,7 @@ module API end def find_group(id) - begin - group = Group.find(id) - rescue ActiveRecord::RecordNotFound - group = Group.find_by!(path: id) - end + group = Group.find_by(path: id) || Group.find_by(id: id) if can?(current_user, :read_group, group) group @@ -134,8 +149,8 @@ module API forbidden! unless current_user.is_admin? end - def authorize!(action, subject) - forbidden! unless abilities.allowed?(current_user, action, subject) + def authorize!(action, subject = nil) + forbidden! unless can?(current_user, action, subject) end def authorize_push_project @@ -153,7 +168,7 @@ module API end def can?(object, action, subject) - abilities.allowed?(object, action, subject) + Ability.allowed?(object, action, subject) end # Checks the occurrences of required attributes, each attribute must be present in the params hash @@ -197,10 +212,6 @@ module API errors end - def validate_access_level?(level) - Gitlab::Access.options_with_owner.values.include? level.to_i - end - # Checks the occurrences of datetime attributes, each attribute if present in the params hash must be in ISO 8601 # format (YYYY-MM-DDTHH:MM:SSZ) or a Bad Request error is invoked. # @@ -278,6 +289,10 @@ module API render_api_error!('304 Not Modified', 304) end + def no_content! + render_api_error!('204 No Content', 204) + end + def render_validation_error!(model) if model.errors.any? render_api_error!(model.errors.messages || '400 Bad Request', 400) @@ -288,6 +303,24 @@ module API error!({ 'message' => message }, status) end + def handle_api_exception(exception) + if sentry_enabled? && report_exception?(exception) + define_params_for_grape_middleware + sentry_context + Raven.capture_exception(exception) + end + + # lifted from https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 + trace = exception.backtrace + + message = "\n#{exception.class} (#{exception.message}):\n" + message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) + message << " " << trace.join("\n ") + + API.logger.add Logger::FATAL, message + rack_response({ 'message' => '500 Internal Server Error' }.to_json, 500) + end + # Projects helpers def filter_projects(projects) @@ -399,21 +432,8 @@ module API links.join(', ') end - def abilities - @abilities ||= begin - abilities = Six.new - abilities << Ability - abilities - end - end - def secret_token - File.read(Gitlab.config.gitlab_shell.secret_file).chomp - end - - def handle_member_errors(errors) - error!(errors[:access_level], 422) if errors[:access_level].any? - not_found!(errors) + Gitlab::Shell.secret_token end def send_git_blob(repository, blob) @@ -433,5 +453,19 @@ module API Entities::Issue end end + + # The Grape Error Middleware only has access to env but no params. We workaround this by + # defining a method that returns the right value. + def define_params_for_grape_middleware + self.define_singleton_method(:params) { Rack::Request.new(env).params.symbolize_keys } + end + + # We could get a Grape or a standard Ruby exception. We should only report anything that + # is clearly an error. + def report_exception?(exception) + return true unless exception.respond_to?(:status) + + exception.status == 500 + end end end diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb new file mode 100644 index 00000000000..90114f6f667 --- /dev/null +++ b/lib/api/helpers/members_helpers.rb @@ -0,0 +1,13 @@ +module API + module Helpers + module MembersHelpers + def find_source(source_type, id) + public_send("find_#{source_type}", id) + end + + def authorize_admin_source!(source_type, source) + authorize! :"admin_#{source_type}", source + end + end + end +end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 959b700de78..9a5d1ece070 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -35,6 +35,14 @@ module API Project.find_with_namespace(project_path) end end + + def ssh_authentication_abilities + [ + :read_project, + :download_code, + :push_code + ] + end end post "/allowed" do @@ -51,9 +59,9 @@ module API access = if wiki? - Gitlab::GitAccessWiki.new(actor, project, protocol) + Gitlab::GitAccessWiki.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) else - Gitlab::GitAccess.new(actor, project, protocol) + Gitlab::GitAccess.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) end access_status = access.check(params[:action], params[:changes]) @@ -74,6 +82,23 @@ module API response end + post "/lfs_authenticate" do + status 200 + + key = Key.find(params[:key_id]) + token_handler = Gitlab::LfsToken.new(key) + + { + username: token_handler.actor_name, + lfs_token: token_handler.token, + repository_http_path: project.http_url_to_repo + } + end + + get "/merge_request_urls" do + ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) + end + # # Discover user by ssh key # @@ -97,6 +122,35 @@ module API {} end end + + post '/two_factor_recovery_codes' do + status 200 + + key = Key.find_by(id: params[:key_id]) + + unless key + return { 'success' => false, 'message' => 'Could not find the given key' } + end + + if key.is_a?(DeployKey) + return { success: false, message: 'Deploy keys cannot be used to retrieve recovery codes' } + end + + user = key.user + + unless user + return { success: false, message: 'Could not find a user for the given key' } + end + + unless user.two_factor_enabled? + return { success: false, message: 'Two-factor authentication is not enabled for this user' } + end + + codes = user.generate_otp_backup_codes! + user.save! + + { success: true, recovery_codes: codes } + end end end end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index c4d3134da6c..c9689e6f8ef 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -3,8 +3,6 @@ module API class Issues < Grape::API before { authenticate! } - helpers ::Gitlab::AkismetHelper - helpers do def filter_issues_state(issues, state) case state @@ -43,7 +41,8 @@ module API issues = current_user.issues.inc_notes_with_associations issues = filter_issues_state(issues, params[:state]) unless params[:state].nil? issues = filter_issues_labels(issues, params[:labels]) unless params[:labels].nil? - issues.reorder(issuable_order_by => issuable_sort) + issues = issues.reorder(issuable_order_by => issuable_sort) + present paginate(issues), with: Entities::Issue, current_user: current_user end end @@ -75,7 +74,11 @@ module API params[:group_id] = group.id params[:milestone_title] = params.delete(:milestone) params[:label_name] = params.delete(:labels) - params[:sort] = "#{params.delete(:order_by)}_#{params.delete(:sort)}" if params[:order_by] && params[:sort] + + if params[:order_by] || params[:sort] + # The Sortable concern takes 'created_desc', not 'created_at_desc' (for example) + params[:sort] = "#{issuable_order_by.sub('_at', '')}_#{issuable_sort}" + end issues = IssuesFinder.new(current_user, params).execute @@ -115,7 +118,8 @@ module API issues = filter_issues_milestone(issues, params[:milestone]) end - issues.reorder(issuable_order_by => issuable_sort) + issues = issues.reorder(issuable_order_by => issuable_sort) + present paginate(issues), with: Entities::Issue, current_user: current_user end @@ -142,12 +146,13 @@ module API # labels (optional) - The labels of an issue # created_at (optional) - Date time string, ISO 8601 formatted # due_date (optional) - Date time string in the format YEAR-MONTH-DAY + # confidential (optional) - Boolean parameter if the issue should be confidential # Example Request: # POST /projects/:id/issues post ':id/issues' do required_attributes! [:title] - keys = [:title, :description, :assignee_id, :milestone_id, :due_date] + keys = [:title, :description, :assignee_id, :milestone_id, :due_date, :confidential] keys << :created_at if current_user.admin? || user_project.owner == current_user attrs = attributes_for_keys(keys) @@ -156,21 +161,19 @@ module API render_api_error!({ labels: errors }, 400) end - project = user_project + attrs[:labels] = params[:labels] if params[:labels] - issue = ::Issues::CreateService.new(project, current_user, attrs.merge(request: request, api: true)).execute + # Convert and filter out invalid confidential flags + attrs['confidential'] = to_boolean(attrs['confidential']) + attrs.delete('confidential') if attrs['confidential'].nil? + + issue = ::Issues::CreateService.new(user_project, current_user, attrs.merge(request: request, api: true)).execute if issue.spam? render_api_error!({ error: 'Spam detected' }, 400) end if issue.valid? - # Find or create labels and attach to issue. Labels are valid because - # we already checked its name, so there can't be an error here - if params[:labels].present? - issue.add_labels_by_names(params[:labels].split(',')) - end - present issue, with: Entities::Issue, current_user: current_user else render_validation_error!(issue) @@ -190,12 +193,13 @@ module API # state_event (optional) - The state event of an issue (close|reopen) # updated_at (optional) - Date time string, ISO 8601 formatted # due_date (optional) - Date time string in the format YEAR-MONTH-DAY + # confidential (optional) - Boolean parameter if the issue should be confidential # Example Request: # PUT /projects/:id/issues/:issue_id put ':id/issues/:issue_id' do issue = user_project.issues.find(params[:issue_id]) authorize! :update_issue, issue - keys = [:title, :description, :assignee_id, :milestone_id, :state_event, :due_date] + keys = [:title, :description, :assignee_id, :milestone_id, :state_event, :due_date, :confidential] keys << :updated_at if current_user.admin? || user_project.owner == current_user attrs = attributes_for_keys(keys) @@ -204,17 +208,15 @@ module API render_api_error!({ labels: errors }, 400) end + attrs[:labels] = params[:labels] if params[:labels] + + # Convert and filter out invalid confidential flags + attrs['confidential'] = to_boolean(attrs['confidential']) + attrs.delete('confidential') if attrs['confidential'].nil? + issue = ::Issues::UpdateService.new(user_project, current_user, attrs).execute(issue) if issue.valid? - # Find or create labels and attach to issue. Labels are valid because - # we already checked its name, so there can't be an error here - if params[:labels] && can?(current_user, :admin_issue, user_project) - issue.remove_labels - # Create and add labels to the new created issue - issue.add_labels_by_names(params[:labels].split(',')) - end - present issue, with: Entities::Issue, current_user: current_user else render_validation_error!(issue) diff --git a/lib/api/keys.rb b/lib/api/keys.rb index 2b723b79504..767f27ef334 100644 --- a/lib/api/keys.rb +++ b/lib/api/keys.rb @@ -4,10 +4,9 @@ module API before { authenticate! } resource :keys do - # Get single ssh key by id. Only available to admin users. - # - # Example Request: - # GET /keys/:id + desc 'Get single ssh key by id. Only available to admin users' do + success Entities::SSHKeyWithUser + end get ":id" do authenticated_as_admin! diff --git a/lib/api/license_templates.rb b/lib/api/license_templates.rb deleted file mode 100644 index d0552299ed0..00000000000 --- a/lib/api/license_templates.rb +++ /dev/null @@ -1,58 +0,0 @@ -module API - # License Templates API - class LicenseTemplates < Grape::API - PROJECT_TEMPLATE_REGEX = - /[\<\{\[] - (project|description| - one\sline\s.+\swhat\sit\sdoes\.) # matching the start and end is enough here - [\>\}\]]/xi.freeze - YEAR_TEMPLATE_REGEX = /[<{\[](year|yyyy)[>}\]]/i.freeze - FULLNAME_TEMPLATE_REGEX = - /[\<\{\[] - (fullname|name\sof\s(author|copyright\sowner)) - [\>\}\]]/xi.freeze - - # Get the list of the available license templates - # - # Parameters: - # popular - Filter licenses to only the popular ones - # - # Example Request: - # GET /licenses - # GET /licenses?popular=1 - get 'licenses' do - options = { - featured: params[:popular].present? ? true : nil - } - present Licensee::License.all(options), with: Entities::RepoLicense - end - - # Get text for specific license - # - # Parameters: - # key (required) - The key of a license - # project - Copyrighted project name - # fullname - Full name of copyright holder - # - # Example Request: - # GET /licenses/mit - # - get 'licenses/:key', requirements: { key: /[\w\.-]+/ } do - required_attributes! [:key] - - not_found!('License') unless Licensee::License.find(params[:key]) - - # We create a fresh Licensee::License object since we'll modify its - # content in place below. - license = Licensee::License.new(params[:key]) - - license.content.gsub!(YEAR_TEMPLATE_REGEX, Time.now.year.to_s) - license.content.gsub!(PROJECT_TEMPLATE_REGEX, params[:project]) if params[:project].present? - - fullname = params[:fullname].presence || current_user.try(:name) - license.content.gsub!(FULLNAME_TEMPLATE_REGEX, fullname) if fullname - - present license, with: Entities::RepoLicense - end - end -end diff --git a/lib/api/lint.rb b/lib/api/lint.rb new file mode 100644 index 00000000000..ae43a4a3237 --- /dev/null +++ b/lib/api/lint.rb @@ -0,0 +1,21 @@ +module API + class Lint < Grape::API + namespace :ci do + desc 'Validation of .gitlab-ci.yml content' + params do + requires :content, type: String, desc: 'Content of .gitlab-ci.yml' + end + post '/lint' do + error = Ci::GitlabCiYamlProcessor.validation_message(params[:content]) + + status 200 + + if error.blank? + { status: 'valid', errors: [] } + else + { status: 'invalid', errors: [error] } + end + end + end + end +end diff --git a/lib/api/members.rb b/lib/api/members.rb new file mode 100644 index 00000000000..b80818f0eb6 --- /dev/null +++ b/lib/api/members.rb @@ -0,0 +1,131 @@ +module API + class Members < Grape::API + before { authenticate! } + + helpers ::API::Helpers::MembersHelpers + + %w[group project].each do |source_type| + params do + requires :id, type: String, desc: "The #{source_type} ID" + end + resource source_type.pluralize do + desc 'Gets a list of group or project members viewable by the authenticated user.' do + success Entities::Member + end + params do + optional :query, type: String, desc: 'A query string to search for members' + end + get ":id/members" do + source = find_source(source_type, params[:id]) + + users = source.users + users = users.merge(User.search(params[:query])) if params[:query] + users = paginate(users) + + present users, with: Entities::Member, source: source + end + + desc 'Gets a member of a group or project.' do + success Entities::Member + end + params do + requires :user_id, type: Integer, desc: 'The user ID of the member' + end + get ":id/members/:user_id" do + source = find_source(source_type, params[:id]) + + members = source.members + member = members.find_by!(user_id: params[:user_id]) + + present member.user, with: Entities::Member, member: member + end + + desc 'Adds a member to a group or project.' do + success Entities::Member + end + params do + requires :user_id, type: Integer, desc: 'The user ID of the new member' + requires :access_level, type: Integer, desc: 'A valid access level (defaults: `30`, developer access level)' + optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY' + end + post ":id/members" do + source = find_source(source_type, params[:id]) + authorize_admin_source!(source_type, source) + + member = source.members.find_by(user_id: params[:user_id]) + + # We need this explicit check because `source.add_user` doesn't + # currently return the member created so it would return 201 even if + # the member already existed... + # The `source_type == 'group'` check is to ensure back-compatibility + # but 409 behavior should be used for both project and group members in 9.0! + conflict!('Member already exists') if source_type == 'group' && member + + unless member + member = source.add_user(params[:user_id], params[:access_level], current_user: current_user, expires_at: params[:expires_at]) + end + + if member.persisted? && member.valid? + present member.user, with: Entities::Member, member: member + else + # This is to ensure back-compatibility but 400 behavior should be used + # for all validation errors in 9.0! + render_api_error!('Access level is not known', 422) if member.errors.key?(:access_level) + render_validation_error!(member) + end + end + + desc 'Updates a member of a group or project.' do + success Entities::Member + end + params do + requires :user_id, type: Integer, desc: 'The user ID of the new member' + requires :access_level, type: Integer, desc: 'A valid access level' + optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY' + end + put ":id/members/:user_id" do + source = find_source(source_type, params[:id]) + authorize_admin_source!(source_type, source) + + member = source.members.find_by!(user_id: params[:user_id]) + attrs = attributes_for_keys [:access_level, :expires_at] + + if member.update_attributes(attrs) + present member.user, with: Entities::Member, member: member + else + # This is to ensure back-compatibility but 400 behavior should be used + # for all validation errors in 9.0! + render_api_error!('Access level is not known', 422) if member.errors.key?(:access_level) + render_validation_error!(member) + end + end + + desc 'Removes a user from a group or project.' + params do + requires :user_id, type: Integer, desc: 'The user ID of the member' + end + delete ":id/members/:user_id" do + source = find_source(source_type, params[:id]) + + # This is to ensure back-compatibility but find_by! should be used + # in that casse in 9.0! + member = source.members.find_by(user_id: params[:user_id]) + + # This is to ensure back-compatibility but this should be removed in + # favor of find_by! in 9.0! + not_found!("Member: user_id:#{params[:user_id]}") if source_type == 'group' && member.nil? + + # This is to ensure back-compatibility but 204 behavior should be used + # for all DELETE endpoints in 9.0! + if member.nil? + { message: "Access revoked", id: params[:user_id].to_i } + else + ::Members::DestroyService.new(source, current_user, declared(params)).execute + + present member.user, with: Entities::Member, member: member + end + end + end + end + end +end diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb new file mode 100644 index 00000000000..07435d78468 --- /dev/null +++ b/lib/api/merge_request_diffs.rb @@ -0,0 +1,45 @@ +module API + # MergeRequestDiff API + class MergeRequestDiffs < Grape::API + before { authenticate! } + + resource :projects do + desc 'Get a list of merge request diff versions' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::MergeRequestDiff + end + + params do + requires :id, type: String, desc: 'The ID of a project' + requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + end + + get ":id/merge_requests/:merge_request_id/versions" do + merge_request = user_project.merge_requests. + find(params[:merge_request_id]) + + authorize! :read_merge_request, merge_request + present merge_request.merge_request_diffs, with: Entities::MergeRequestDiff + end + + desc 'Get a single merge request diff version' do + detail 'This feature was introduced in GitLab 8.12.' + success Entities::MergeRequestDiffFull + end + + params do + requires :id, type: String, desc: 'The ID of a project' + requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' + requires :version_id, type: Integer, desc: 'The ID of a merge request diff version' + end + + get ":id/merge_requests/:merge_request_id/versions/:version_id" do + merge_request = user_project.merge_requests. + find(params[:merge_request_id]) + + authorize! :read_merge_request, merge_request + present merge_request.merge_request_diffs.find(params[:version_id]), with: Entities::MergeRequestDiffFull + end + end + end +end diff --git a/lib/api/milestones.rb b/lib/api/milestones.rb index 7a0cb7c99f3..9b73f6826cf 100644 --- a/lib/api/milestones.rb +++ b/lib/api/milestones.rb @@ -108,8 +108,7 @@ module API finder_params = { project_id: user_project.id, - milestone_title: @milestone.title, - state: 'all' + milestone_title: @milestone.title } issues = IssuesFinder.new(current_user, finder_params).execute diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index 50d3729449e..fe981d7b9fa 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -4,20 +4,18 @@ module API before { authenticate! } resource :namespaces do - # Get a namespaces list - # - # Example Request: - # GET /namespaces + desc 'Get a namespaces list' do + success Entities::Namespace + end + params do + optional :search, type: String, desc: "Search query for namespaces" + end get do - @namespaces = if current_user.admin - Namespace.all - else - current_user.namespaces - end - @namespaces = @namespaces.search(params[:search]) if params[:search].present? - @namespaces = paginate @namespaces + namespaces = current_user.admin ? Namespace.all : current_user.namespaces + + namespaces = namespaces.search(params[:search]) if params[:search].present? - present @namespaces, with: Entities::Namespace + present paginate(namespaces), with: Entities::Namespace end end end diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 8bfa998dc53..c5c214d4d13 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -83,12 +83,12 @@ module API opts[:created_at] = params[:created_at] end - @note = ::Notes::CreateService.new(user_project, current_user, opts).execute + note = ::Notes::CreateService.new(user_project, current_user, opts).execute - if @note.valid? - present @note, with: Entities::Note + if note.valid? + present note, with: Entities::const_get(note.class.name) else - not_found!("Note #{@note.errors.messages}") + not_found!("Note #{note.errors.messages}") end end diff --git a/lib/api/notification_settings.rb b/lib/api/notification_settings.rb new file mode 100644 index 00000000000..a70a7e71073 --- /dev/null +++ b/lib/api/notification_settings.rb @@ -0,0 +1,97 @@ +module API + # notification_settings API + class NotificationSettings < Grape::API + before { authenticate! } + + helpers ::API::Helpers::MembersHelpers + + resource :notification_settings do + desc 'Get global notification level settings and email, defaults to Participate' do + detail 'This feature was introduced in GitLab 8.12' + success Entities::GlobalNotificationSetting + end + get do + notification_setting = current_user.global_notification_setting + + present notification_setting, with: Entities::GlobalNotificationSetting + end + + desc 'Update global notification level settings and email, defaults to Participate' do + detail 'This feature was introduced in GitLab 8.12' + success Entities::GlobalNotificationSetting + end + params do + optional :level, type: String, desc: 'The global notification level' + optional :notification_email, type: String, desc: 'The email address to send notifications' + NotificationSetting::EMAIL_EVENTS.each do |event| + optional event, type: Boolean, desc: 'Enable/disable this notification' + end + end + put do + notification_setting = current_user.global_notification_setting + + begin + notification_setting.transaction do + new_notification_email = params.delete(:notification_email) + declared_params = declared(params, include_missing: false).to_h + + current_user.update(notification_email: new_notification_email) if new_notification_email + notification_setting.update(declared_params) + end + rescue ArgumentError => e # catch level enum error + render_api_error! e.to_s, 400 + end + + render_validation_error! current_user + render_validation_error! notification_setting + present notification_setting, with: Entities::GlobalNotificationSetting + end + end + + %w[group project].each do |source_type| + resource source_type.pluralize do + desc "Get #{source_type} level notification level settings, defaults to Global" do + detail 'This feature was introduced in GitLab 8.12' + success Entities::NotificationSetting + end + params do + requires :id, type: String, desc: 'The group ID or project ID or project NAMESPACE/PROJECT_NAME' + end + get ":id/notification_settings" do + source = find_source(source_type, params[:id]) + + notification_setting = current_user.notification_settings_for(source) + + present notification_setting, with: Entities::NotificationSetting + end + + desc "Update #{source_type} level notification level settings, defaults to Global" do + detail 'This feature was introduced in GitLab 8.12' + success Entities::NotificationSetting + end + params do + requires :id, type: String, desc: 'The group ID or project ID or project NAMESPACE/PROJECT_NAME' + optional :level, type: String, desc: "The #{source_type} notification level" + NotificationSetting::EMAIL_EVENTS.each do |event| + optional event, type: Boolean, desc: 'Enable/disable this notification' + end + end + put ":id/notification_settings" do + source = find_source(source_type, params.delete(:id)) + notification_setting = current_user.notification_settings_for(source) + + begin + declared_params = declared(params, include_missing: false).to_h + + notification_setting.update(declared_params) + rescue ArgumentError => e # catch level enum error + render_api_error! e.to_s, 400 + end + + render_validation_error! notification_setting + present notification_setting, with: Entities::NotificationSetting + end + end + end + end +end diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb new file mode 100644 index 00000000000..2a0c8e1f2c0 --- /dev/null +++ b/lib/api/pipelines.rb @@ -0,0 +1,77 @@ +module API + class Pipelines < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects do + desc 'Get all Pipelines of the project' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Pipeline + end + params do + optional :page, type: Integer, desc: 'Page number of the current request' + optional :per_page, type: Integer, desc: 'Number of items per page' + optional :scope, type: String, values: ['running', 'branches', 'tags'], + desc: 'Either running, branches, or tags' + end + get ':id/pipelines' do + authorize! :read_pipeline, user_project + + pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope]) + present paginate(pipelines), with: Entities::Pipeline + end + + desc 'Gets a specific pipeline for the project' do + detail 'This feature was introduced in GitLab 8.11' + success Entities::Pipeline + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + end + get ':id/pipelines/:pipeline_id' do + authorize! :read_pipeline, user_project + + present pipeline, with: Entities::Pipeline + end + + desc 'Retry failed builds in the pipeline' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Pipeline + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + end + post ':id/pipelines/:pipeline_id/retry' do + authorize! :update_pipeline, user_project + + pipeline.retry_failed(current_user) + + present pipeline, with: Entities::Pipeline + end + + desc 'Cancel all builds in the pipeline' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Pipeline + end + params do + requires :pipeline_id, type: Integer, desc: 'The pipeline ID' + end + post ':id/pipelines/:pipeline_id/cancel' do + authorize! :update_pipeline, user_project + + pipeline.cancel_running + + status 200 + present pipeline.reload, with: Entities::Pipeline + end + end + + helpers do + def pipeline + @pipeline ||= user_project.pipelines.find(params[:pipeline_id]) + end + end + end +end diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 6bb70bc8bc3..14f5be3b5f6 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -45,6 +45,8 @@ module API :tag_push_events, :note_events, :build_events, + :pipeline_events, + :wiki_page_events, :enable_ssl_verification ] @hook = user_project.hooks.new(attrs) @@ -78,6 +80,8 @@ module API :tag_push_events, :note_events, :build_events, + :pipeline_events, + :wiki_page_events, :enable_ssl_verification ] diff --git a/lib/api/project_members.rb b/lib/api/project_members.rb deleted file mode 100644 index 6a0b3e7d134..00000000000 --- a/lib/api/project_members.rb +++ /dev/null @@ -1,110 +0,0 @@ -module API - # Projects members API - class ProjectMembers < Grape::API - before { authenticate! } - - resource :projects do - # Get a project team members - # - # Parameters: - # id (required) - The ID of a project - # query - Query string - # Example Request: - # GET /projects/:id/members - get ":id/members" do - if params[:query].present? - @members = paginate user_project.users.where("username LIKE ?", "%#{params[:query]}%") - else - @members = paginate user_project.users - end - present @members, with: Entities::ProjectMember, project: user_project - end - - # Get a project team members - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a user - # Example Request: - # GET /projects/:id/members/:user_id - get ":id/members/:user_id" do - @member = user_project.users.find params[:user_id] - present @member, with: Entities::ProjectMember, project: user_project - end - - # Add a new project team member - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a user - # access_level (required) - Project access level - # Example Request: - # POST /projects/:id/members - post ":id/members" do - authorize! :admin_project, user_project - required_attributes! [:user_id, :access_level] - - # either the user is already a team member or a new one - project_member = user_project.project_member(params[:user_id]) - if project_member.nil? - project_member = user_project.project_members.new( - user_id: params[:user_id], - access_level: params[:access_level] - ) - end - - if project_member.save - @member = project_member.user - present @member, with: Entities::ProjectMember, project: user_project - else - handle_member_errors project_member.errors - end - end - - # Update project team member - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a team member - # access_level (required) - Project access level - # Example Request: - # PUT /projects/:id/members/:user_id - put ":id/members/:user_id" do - authorize! :admin_project, user_project - required_attributes! [:access_level] - - project_member = user_project.project_members.find_by(user_id: params[:user_id]) - not_found!("User can not be found") if project_member.nil? - - if project_member.update_attributes(access_level: params[:access_level]) - @member = project_member.user - present @member, with: Entities::ProjectMember, project: user_project - else - handle_member_errors project_member.errors - end - end - - # Remove a team member from project - # - # Parameters: - # id (required) - The ID of a project - # user_id (required) - The ID of a team member - # Example Request: - # DELETE /projects/:id/members/:user_id - delete ":id/members/:user_id" do - project_member = user_project.project_members.find_by(user_id: params[:user_id]) - - unless current_user.can?(:admin_project, user_project) || - current_user.can?(:destroy_project_member, project_member) - forbidden! - end - - if project_member.nil? - { message: "Access revoked", id: params[:user_id].to_i } - else - project_member.destroy - end - end - end - end -end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 8fed7db8803..da16e24d7ea 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -22,14 +22,25 @@ module API # Example Request: # GET /projects get do - @projects = current_user.authorized_projects - @projects = filter_projects(@projects) - @projects = paginate @projects - if params[:simple] - present @projects, with: Entities::BasicProjectDetails, user: current_user - else - present @projects, with: Entities::ProjectWithAccess, user: current_user - end + projects = current_user.authorized_projects + projects = filter_projects(projects) + projects = paginate projects + entity = params[:simple] ? Entities::BasicProjectDetails : Entities::ProjectWithAccess + + present projects, with: entity, user: current_user + end + + # Get a list of visible projects for authenticated user + # + # Example Request: + # GET /projects/visible + get '/visible' do + projects = ProjectsFinder.new.execute(current_user) + projects = filter_projects(projects) + projects = paginate projects + entity = params[:simple] ? Entities::BasicProjectDetails : Entities::ProjectWithAccess + + present projects, with: entity, user: current_user end # Get an owned projects list for authenticated user @@ -37,10 +48,10 @@ module API # Example Request: # GET /projects/owned get '/owned' do - @projects = current_user.owned_projects - @projects = filter_projects(@projects) - @projects = paginate @projects - present @projects, with: Entities::ProjectWithAccess, user: current_user + projects = current_user.owned_projects + projects = filter_projects(projects) + projects = paginate projects + present projects, with: Entities::ProjectWithAccess, user: current_user end # Gets starred project for the authenticated user @@ -48,10 +59,10 @@ module API # Example Request: # GET /projects/starred get '/starred' do - @projects = current_user.viewable_starred_projects - @projects = filter_projects(@projects) - @projects = paginate @projects - present @projects, with: Entities::Project + projects = current_user.viewable_starred_projects + projects = filter_projects(projects) + projects = paginate projects + present projects, with: Entities::Project, user: current_user end # Get all projects for admin user @@ -60,10 +71,10 @@ module API # GET /projects/all get '/all' do authenticated_as_admin! - @projects = Project.all - @projects = filter_projects(@projects) - @projects = paginate @projects - present @projects, with: Entities::ProjectWithAccess, user: current_user + projects = Project.all + projects = filter_projects(projects) + projects = paginate projects + present projects, with: Entities::ProjectWithAccess, user: current_user end # Get a single project @@ -91,8 +102,8 @@ module API # Create new project # # Parameters: - # name (required) - name for new project - # description (optional) - short project description + # name (required) - name for new project + # description (optional) - short project description # issues_enabled (optional) # merge_requests_enabled (optional) # builds_enabled (optional) @@ -100,30 +111,35 @@ module API # snippets_enabled (optional) # container_registry_enabled (optional) # shared_runners_enabled (optional) - # namespace_id (optional) - defaults to user namespace - # public (optional) - if true same as setting visibility_level = 20 - # visibility_level (optional) - 0 by default + # namespace_id (optional) - defaults to user namespace + # public (optional) - if true same as setting visibility_level = 20 + # visibility_level (optional) - 0 by default # import_url (optional) # public_builds (optional) + # lfs_enabled (optional) + # request_access_enabled (optional) - Allow users to request member access # Example Request # POST /projects post do required_attributes! [:name] - attrs = attributes_for_keys [:name, - :path, + attrs = attributes_for_keys [:builds_enabled, + :container_registry_enabled, :description, + :import_url, :issues_enabled, + :lfs_enabled, :merge_requests_enabled, - :builds_enabled, - :wiki_enabled, - :snippets_enabled, - :container_registry_enabled, - :shared_runners_enabled, + :name, :namespace_id, + :only_allow_merge_if_build_succeeds, + :path, :public, + :public_builds, + :request_access_enabled, + :shared_runners_enabled, + :snippets_enabled, :visibility_level, - :import_url, - :public_builds] + :wiki_enabled] attrs = map_public_to_visibility_level(attrs) @project = ::Projects::CreateService.new(current_user, attrs).execute if @project.saved? @@ -140,10 +156,10 @@ module API # Create new project for a specified user. Only available to admin users. # # Parameters: - # user_id (required) - The ID of a user - # name (required) - name for new project - # description (optional) - short project description - # default_branch (optional) - 'master' by default + # user_id (required) - The ID of a user + # name (required) - name for new project + # description (optional) - short project description + # default_branch (optional) - 'master' by default # issues_enabled (optional) # merge_requests_enabled (optional) # builds_enabled (optional) @@ -151,28 +167,33 @@ module API # snippets_enabled (optional) # container_registry_enabled (optional) # shared_runners_enabled (optional) - # public (optional) - if true same as setting visibility_level = 20 + # public (optional) - if true same as setting visibility_level = 20 # visibility_level (optional) # import_url (optional) # public_builds (optional) + # lfs_enabled (optional) + # request_access_enabled (optional) - Allow users to request member access # Example Request # POST /projects/user/:user_id post "user/:user_id" do authenticated_as_admin! user = User.find(params[:user_id]) - attrs = attributes_for_keys [:name, - :description, + attrs = attributes_for_keys [:builds_enabled, :default_branch, + :description, + :import_url, :issues_enabled, + :lfs_enabled, :merge_requests_enabled, - :builds_enabled, - :wiki_enabled, - :snippets_enabled, - :shared_runners_enabled, + :name, + :only_allow_merge_if_build_succeeds, :public, + :public_builds, + :request_access_enabled, + :shared_runners_enabled, + :snippets_enabled, :visibility_level, - :import_url, - :public_builds] + :wiki_enabled] attrs = map_public_to_visibility_level(attrs) @project = ::Projects::CreateService.new(user, attrs).execute if @project.saved? @@ -183,16 +204,32 @@ module API end end - # Fork new project for the current user. + # Fork new project for the current user or provided namespace. # # Parameters: # id (required) - The ID of a project + # namespace (optional) - The ID or name of the namespace that the project will be forked into. # Example Request # POST /projects/fork/:id post 'fork/:id' do + attrs = {} + namespace_id = params[:namespace] + + if namespace_id.present? + namespace = Namespace.find_by(id: namespace_id) || Namespace.find_by_path_or_name(namespace_id) + + unless namespace && can?(current_user, :create_projects, namespace) + not_found!('Target Namespace') + end + + attrs[:namespace] = namespace + end + @forked_project = ::Projects::ForkService.new(user_project, - current_user).execute + current_user, + attrs).execute + if @forked_project.errors.any? conflict!(@forked_project.errors.messages) else @@ -218,23 +255,27 @@ module API # public (optional) - if true same as setting visibility_level = 20 # visibility_level (optional) - visibility level of a project # public_builds (optional) + # lfs_enabled (optional) # Example Request # PUT /projects/:id put ':id' do - attrs = attributes_for_keys [:name, - :path, - :description, + attrs = attributes_for_keys [:builds_enabled, + :container_registry_enabled, :default_branch, + :description, :issues_enabled, + :lfs_enabled, :merge_requests_enabled, - :builds_enabled, - :wiki_enabled, - :snippets_enabled, - :container_registry_enabled, - :shared_runners_enabled, + :name, + :only_allow_merge_if_build_succeeds, + :path, :public, + :public_builds, + :request_access_enabled, + :shared_runners_enabled, + :snippets_enabled, :visibility_level, - :public_builds] + :wiki_enabled] attrs = map_public_to_visibility_level(attrs) authorize_admin_project authorize! :rename_project, user_project if attrs[:name].present? @@ -323,7 +364,7 @@ module API # DELETE /projects/:id delete ":id" do authorize! :remove_project, user_project - ::Projects::DestroyService.new(user_project, current_user, {}).pending_delete! + ::Projects::DestroyService.new(user_project, current_user, {}).async_execute end # Mark this project as forked from another @@ -363,23 +404,30 @@ module API # Share project with group # # Parameters: - # id (required) - The ID of a project - # group_id (required) - The ID of a group + # id (required) - The ID of a project + # group_id (required) - The ID of a group # group_access (required) - Level of permissions for sharing + # expires_at (optional) - Share expiration date # # Example Request: # POST /projects/:id/share post ":id/share" do authorize! :admin_project, user_project required_attributes! [:group_id, :group_access] + attrs = attributes_for_keys [:group_id, :group_access, :expires_at] + + group = Group.find_by_id(attrs[:group_id]) + + unless group && can?(current_user, :read_group, group) + not_found!('Group') + end unless user_project.allowed_to_share_with_group? return render_api_error!("The project sharing with group is disabled", 400) end - link = user_project.project_group_links.new - link.group_id = params[:group_id] - link.group_access = params[:group_access] + link = user_project.project_group_links.new(attrs) + if link.save present link, with: Entities::ProjectGroupLink else @@ -405,18 +453,9 @@ module API # Example Request: # GET /projects/search/:query get "/search/:query" do - ids = current_user.authorized_projects.map(&:id) - visibility_levels = [ Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC ] - projects = Project.where("(id in (?) OR visibility_level in (?)) AND (name LIKE (?))", ids, visibility_levels, "%#{params[:query]}%") - sort = params[:sort] == 'desc' ? 'desc' : 'asc' - - projects = case params["order_by"] - when 'id' then projects.order("id #{sort}") - when 'name' then projects.order("name #{sort}") - when 'created_at' then projects.order("created_at #{sort}") - when 'last_activity_at' then projects.order("last_activity_at #{sort}") - else projects - end + search_service = Search::GlobalService.new(current_user, search: params[:query]).execute + projects = search_service.objects('projects', params[:page]) + projects = projects.reorder(project_order_by => project_sort) present paginate(projects), with: Entities::Project end diff --git a/lib/api/session.rb b/lib/api/session.rb index 56c202f1294..55ec66a6d67 100644 --- a/lib/api/session.rb +++ b/lib/api/session.rb @@ -14,6 +14,7 @@ module API user = Gitlab::Auth.find_with_user_password(params[:email] || params[:login], params[:password]) return unauthorized! unless user + return render_api_error!('401 Unauthorized. You have 2FA enabled. Please use a personal access token to access the API', 401) if user.two_factor_enabled? present user, with: Entities::UserLogin end end diff --git a/lib/api/system_hooks.rb b/lib/api/system_hooks.rb index 22b8f90dc5c..2e76b91051f 100644 --- a/lib/api/system_hooks.rb +++ b/lib/api/system_hooks.rb @@ -7,38 +7,36 @@ module API end resource :hooks do - # Get the list of system hooks - # - # Example Request: - # GET /hooks + desc 'Get the list of system hooks' do + success Entities::Hook + end get do - @hooks = SystemHook.all - present @hooks, with: Entities::Hook + hooks = SystemHook.all + present hooks, with: Entities::Hook end - # Create new system hook - # - # Parameters: - # url (required) - url for system hook - # Example Request - # POST /hooks + desc 'Create a new system hook' do + success Entities::Hook + end + params do + requires :url, type: String, desc: 'The URL for the system hook' + end post do - attrs = attributes_for_keys [:url] - required_attributes! [:url] - @hook = SystemHook.new attrs - if @hook.save - present @hook, with: Entities::Hook + hook = SystemHook.new declared(params).to_h + + if hook.save + present hook, with: Entities::Hook else not_found! end end - # Test a hook - # - # Example Request - # GET /hooks/:id + desc 'Test a hook' + params do + requires :id, type: Integer, desc: 'The ID of the system hook' + end get ":id" do - @hook = SystemHook.find(params[:id]) + hook = SystemHook.find(params[:id]) data = { event_name: "project_create", name: "Ruby", @@ -47,20 +45,20 @@ module API owner_name: "Someone", owner_email: "example@gitlabhq.com" } - @hook.execute(data, 'system_hooks') + hook.execute(data, 'system_hooks') data end - # Delete a hook. This is an idempotent function. - # - # Parameters: - # id (required) - ID of the hook - # Example Request: - # DELETE /hooks/:id + desc 'Delete a hook' do + success Entities::Hook + end + params do + requires :id, type: Integer, desc: 'The ID of the system hook' + end delete ":id" do begin - @hook = SystemHook.find(params[:id]) - @hook.destroy + hook = SystemHook.find(params[:id]) + present hook.destroy, with: Entities::Hook rescue # SystemHook raises an Error if no hook with id found end diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 18408797756..8a53d9c0095 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -1,35 +1,115 @@ module API class Templates < Grape::API - TEMPLATE_TYPES = { - gitignores: Gitlab::Template::Gitignore, - gitlab_ci_ymls: Gitlab::Template::GitlabCiYml + GLOBAL_TEMPLATE_TYPES = { + gitignores: { + klass: Gitlab::Template::GitignoreTemplate, + gitlab_version: 8.8 + }, + gitlab_ci_ymls: { + klass: Gitlab::Template::GitlabCiYmlTemplate, + gitlab_version: 8.9 + } }.freeze + PROJECT_TEMPLATE_REGEX = + /[\<\{\[] + (project|description| + one\sline\s.+\swhat\sit\sdoes\.) # matching the start and end is enough here + [\>\}\]]/xi.freeze + YEAR_TEMPLATE_REGEX = /[<{\[](year|yyyy)[>}\]]/i.freeze + FULLNAME_TEMPLATE_REGEX = + /[\<\{\[] + (fullname|name\sof\s(author|copyright\sowner)) + [\>\}\]]/xi.freeze + DEPRECATION_MESSAGE = ' This endpoint is deprecated and will be removed in GitLab 9.0.'.freeze - TEMPLATE_TYPES.each do |template, klass| - # Get the list of the available template - # - # Example Request: - # GET /gitignores - # GET /gitlab_ci_ymls - get template.to_s do - present klass.all, with: Entities::TemplatesList - end - - # Get the text for a specific template - # - # Parameters: - # name (required) - The name of a template - # - # Example Request: - # GET /gitignores/Elixir - # GET /gitlab_ci_ymls/Ruby - get "#{template}/:name" do - required_attributes! [:name] - - new_template = klass.find(params[:name]) - not_found!(template.to_s.singularize) unless new_template - - present new_template, with: Entities::Template + helpers do + def parsed_license_template + # We create a fresh Licensee::License object since we'll modify its + # content in place below. + template = Licensee::License.new(params[:name]) + + template.content.gsub!(YEAR_TEMPLATE_REGEX, Time.now.year.to_s) + template.content.gsub!(PROJECT_TEMPLATE_REGEX, params[:project]) if params[:project].present? + + fullname = params[:fullname].presence || current_user.try(:name) + template.content.gsub!(FULLNAME_TEMPLATE_REGEX, fullname) if fullname + template + end + + def render_response(template_type, template) + not_found!(template_type.to_s.singularize) unless template + present template, with: Entities::Template + end + end + + { "licenses" => :deprecated, "templates/licenses" => :ok }.each do |route, status| + desc 'Get the list of the available license template' do + detailed_desc = 'This feature was introduced in GitLab 8.7.' + detailed_desc << DEPRECATION_MESSAGE unless status == :ok + detail detailed_desc + success Entities::RepoLicense + end + params do + optional :popular, type: Boolean, desc: 'If passed, returns only popular licenses' + end + get route do + options = { + featured: declared(params).popular.present? ? true : nil + } + present Licensee::License.all(options), with: Entities::RepoLicense + end + end + + { "licenses/:name" => :deprecated, "templates/licenses/:name" => :ok }.each do |route, status| + desc 'Get the text for a specific license' do + detailed_desc = 'This feature was introduced in GitLab 8.7.' + detailed_desc << DEPRECATION_MESSAGE unless status == :ok + detail detailed_desc + success Entities::RepoLicense + end + params do + requires :name, type: String, desc: 'The name of the template' + end + get route, requirements: { name: /[\w\.-]+/ } do + not_found!('License') unless Licensee::License.find(declared(params).name) + + template = parsed_license_template + + present template, with: Entities::RepoLicense + end + end + + GLOBAL_TEMPLATE_TYPES.each do |template_type, properties| + klass = properties[:klass] + gitlab_version = properties[:gitlab_version] + + { template_type => :deprecated, "templates/#{template_type}" => :ok }.each do |route, status| + desc 'Get the list of the available template' do + detailed_desc = "This feature was introduced in GitLab #{gitlab_version}." + detailed_desc << DEPRECATION_MESSAGE unless status == :ok + detail detailed_desc + success Entities::TemplatesList + end + get route do + present klass.all, with: Entities::TemplatesList + end + end + + { "#{template_type}/:name" => :deprecated, "templates/#{template_type}/:name" => :ok }.each do |route, status| + desc 'Get the text for a specific template present in local filesystem' do + detailed_desc = "This feature was introduced in GitLab #{gitlab_version}." + detailed_desc << DEPRECATION_MESSAGE unless status == :ok + detail detailed_desc + success Entities::Template + end + params do + requires :name, type: String, desc: 'The name of the template' + end + get route do + new_template = klass.find(declared(params).name) + + render_response(template_type, new_template) + end end end end diff --git a/lib/api/todos.rb b/lib/api/todos.rb index 26c24c3baff..832b04a3bb1 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -8,18 +8,19 @@ module API 'issues' => ->(id) { find_project_issue(id) } } + params do + requires :id, type: String, desc: 'The ID of a project' + end resource :projects do ISSUABLE_TYPES.each do |type, finder| type_id_str = "#{type.singularize}_id".to_sym - # Create a todo on an issuable - # - # Parameters: - # id (required) - The ID of a project - # issuable_id (required) - The ID of an issuable - # Example Request: - # POST /projects/:id/issues/:issuable_id/todo - # POST /projects/:id/merge_requests/:issuable_id/todo + desc 'Create a todo on an issuable' do + success Entities::Todo + end + params do + requires type_id_str, type: Integer, desc: 'The ID of an issuable' + end post ":id/#{type}/:#{type_id_str}/todo" do issuable = instance_exec(params[type_id_str], &finder) todo = TodoService.new.mark_todo(issuable, current_user).first @@ -40,42 +41,32 @@ module API end end - # Get a todo list - # - # Example Request: - # GET /todos - # + desc 'Get a todo list' do + success Entities::Todo + end get do todos = find_todos present paginate(todos), with: Entities::Todo, current_user: current_user end - # Mark a todo as done - # - # Parameters: - # id: (required) - The ID of the todo being marked as done - # - # Example Request: - # DELETE /todos/:id - # + desc 'Mark a todo as done' do + success Entities::Todo + end + params do + requires :id, type: Integer, desc: 'The ID of the todo being marked as done' + end delete ':id' do todo = current_user.todos.find(params[:id]) - todo.done + TodoService.new.mark_todos_as_done([todo], current_user) - present todo, with: Entities::Todo, current_user: current_user + present todo.reload, with: Entities::Todo, current_user: current_user end - # Mark all todos as done - # - # Example Request: - # DELETE /todos - # + desc 'Mark all todos as done' delete do todos = find_todos - todos.each(&:done) - - todos.length + TodoService.new.mark_todos_as_done(todos, current_user) end end end diff --git a/lib/api/users.rb b/lib/api/users.rb index 8a376d3c2a3..e868f628404 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -60,6 +60,7 @@ module API # linkedin - Linkedin # twitter - Twitter account # website_url - Website url + # organization - Organization # projects_limit - Number of projects user can create # extern_uid - External authentication provider UID # provider - External provider @@ -74,7 +75,7 @@ module API post do authenticated_as_admin! required_attributes! [:email, :password, :name, :username] - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :confirm, :external] + attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :confirm, :external, :organization] admin = attrs.delete(:admin) confirm = !(attrs.delete(:confirm) =~ /(false|f|no|0)$/i) user = User.build_user(attrs) @@ -111,6 +112,7 @@ module API # linkedin - Linkedin # twitter - Twitter account # website_url - Website url + # organization - Organization # projects_limit - Limit projects each user can create # bio - Bio # location - Location of the user @@ -122,7 +124,7 @@ module API put ":id" do authenticated_as_admin! - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :external] + attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :external, :organization] user = User.find(params[:id]) not_found!('User') unless user @@ -319,6 +321,26 @@ module API user.activate end end + + desc 'Get contribution events of a specified user' do + detail 'This feature was introduced in GitLab 8.13.' + success Entities::Event + end + params do + requires :id, type: String, desc: 'The user ID' + end + get ':id/events' do + user = User.find_by(id: declared(params).id) + not_found!('User') unless user + + events = user.recent_events. + merge(ProjectsFinder.new.execute(current_user)). + references(:project). + with_associations. + page(params[:page]) + + present paginate(events), with: Entities::Event + end end resource :user do @@ -327,7 +349,7 @@ module API # Example Request: # GET /user get do - present @current_user, with: Entities::UserLogin + present @current_user, with: Entities::UserFull end # Get currently authenticated user's keys diff --git a/lib/api/variables.rb b/lib/api/variables.rb index f6495071a11..b9fb3c21dbb 100644 --- a/lib/api/variables.rb +++ b/lib/api/variables.rb @@ -4,27 +4,29 @@ module API before { authenticate! } before { authorize! :admin_build, user_project } + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects do - # Get project variables - # - # Parameters: - # id (required) - The ID of a project - # page (optional) - The page number for pagination - # per_page (optional) - The value of items per page to show - # Example Request: - # GET /projects/:id/variables + desc 'Get project variables' do + success Entities::Variable + end + params do + optional :page, type: Integer, desc: 'The page number for pagination' + optional :per_page, type: Integer, desc: 'The value of items per page to show' + end get ':id/variables' do variables = user_project.variables present paginate(variables), with: Entities::Variable end - # Get specific variable of a project - # - # Parameters: - # id (required) - The ID of a project - # key (required) - The `key` of variable - # Example Request: - # GET /projects/:id/variables/:key + desc 'Get a specific variable from a project' do + success Entities::Variable + end + params do + requires :key, type: String, desc: 'The key of the variable' + end get ':id/variables/:key' do key = params[:key] variable = user_project.variables.find_by(key: key.to_s) @@ -34,18 +36,15 @@ module API present variable, with: Entities::Variable end - # Create a new variable in project - # - # Parameters: - # id (required) - The ID of a project - # key (required) - The key of variable - # value (required) - The value of variable - # Example Request: - # POST /projects/:id/variables + desc 'Create a new variable in a project' do + success Entities::Variable + end + params do + requires :key, type: String, desc: 'The key of the variable' + requires :value, type: String, desc: 'The value of the variable' + end post ':id/variables' do - required_attributes! [:key, :value] - - variable = user_project.variables.create(key: params[:key], value: params[:value]) + variable = user_project.variables.create(declared(params, include_parent_namespaces: false).to_h) if variable.valid? present variable, with: Entities::Variable @@ -54,41 +53,37 @@ module API end end - # Update existing variable of a project - # - # Parameters: - # id (required) - The ID of a project - # key (optional) - The `key` of variable - # value (optional) - New value for `value` field of variable - # Example Request: - # PUT /projects/:id/variables/:key + desc 'Update an existing variable from a project' do + success Entities::Variable + end + params do + optional :key, type: String, desc: 'The key of the variable' + optional :value, type: String, desc: 'The value of the variable' + end put ':id/variables/:key' do - variable = user_project.variables.find_by(key: params[:key].to_s) + variable = user_project.variables.find_by(key: params[:key]) return not_found!('Variable') unless variable - attrs = attributes_for_keys [:value] - if variable.update(attrs) + if variable.update(value: params[:value]) present variable, with: Entities::Variable else render_validation_error!(variable) end end - # Delete existing variable of a project - # - # Parameters: - # id (required) - The ID of a project - # key (required) - The ID of a variable - # Example Request: - # DELETE /projects/:id/variables/:key + desc 'Delete an existing variable from a project' do + success Entities::Variable + end + params do + requires :key, type: String, desc: 'The key of the variable' + end delete ':id/variables/:key' do - variable = user_project.variables.find_by(key: params[:key].to_s) + variable = user_project.variables.find_by(key: params[:key]) return not_found!('Variable') unless variable - variable.destroy - present variable, with: Entities::Variable + present variable.destroy, with: Entities::Variable end end end diff --git a/lib/api/version.rb b/lib/api/version.rb new file mode 100644 index 00000000000..9ba576bd828 --- /dev/null +++ b/lib/api/version.rb @@ -0,0 +1,12 @@ +module API + class Version < Grape::API + before { authenticate! } + + desc 'Get the version information of the GitLab instance.' do + detail 'This feature was introduced in GitLab 8.13.' + end + get '/version' do + { version: Gitlab::VERSION, revision: Gitlab::REVISION } + end + end +end diff --git a/lib/backup/files.rb b/lib/backup/files.rb index 654b4d1c896..cedbb289f6a 100644 --- a/lib/backup/files.rb +++ b/lib/backup/files.rb @@ -27,7 +27,7 @@ module Backup def backup_existing_files_dir timestamped_files_path = File.join(files_parent_dir, "#{name}.#{Time.now.to_i}") - if File.exists?(app_files_dir) + if File.exist?(app_files_dir) FileUtils.mv(app_files_dir, File.expand_path(timestamped_files_path)) end end diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb index 2ff3e3bdfb0..0dfffaf0bc6 100644 --- a/lib/backup/manager.rb +++ b/lib/backup/manager.rb @@ -114,7 +114,7 @@ module Backup tar_file = ENV["BACKUP"].nil? ? File.join("#{file_list.first}_gitlab_backup.tar") : File.join(ENV["BACKUP"] + "_gitlab_backup.tar") - unless File.exists?(tar_file) + unless File.exist?(tar_file) puts "The specified backup doesn't exist!" exit 1 end diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index 1f5917b8127..9fcd9a3f999 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -28,7 +28,7 @@ module Backup wiki = ProjectWiki.new(project) - if File.exists?(path_to_repo(wiki)) + if File.exist?(path_to_repo(wiki)) $progress.print " * #{wiki.path_with_namespace} ... " if wiki.repository.empty? $progress.puts " [SKIPPED]".color(:cyan) @@ -49,13 +49,13 @@ module Backup def restore Gitlab.config.repositories.storages.each do |name, path| - next unless File.exists?(path) + next unless File.exist?(path) # Move repos dir to 'repositories.old' dir bk_repos_path = File.join(path, '..', 'repositories.old.' + Time.now.to_i.to_s) FileUtils.mv(path, bk_repos_path) # This is expected from gitlab:check - FileUtils.mkdir_p(path, mode: 2770) + FileUtils.mkdir_p(path, mode: 02770) end Project.find_each(batch_size: 1000) do |project| @@ -63,7 +63,7 @@ module Backup project.ensure_dir_exist - if File.exists?(path_to_bundle(project)) + if File.exist?(path_to_bundle(project)) FileUtils.mkdir_p(path_to_repo(project)) cmd = %W(tar -xf #{path_to_bundle(project)} -C #{path_to_repo(project)}) else @@ -80,7 +80,7 @@ module Backup wiki = ProjectWiki.new(project) - if File.exists?(path_to_bundle(wiki)) + if File.exist?(path_to_bundle(wiki)) $progress.print " * #{wiki.path_with_namespace} ... " # If a wiki bundle exists, first remove the empty repo diff --git a/lib/banzai.rb b/lib/banzai.rb index 9ebe379f454..35ca234c1ba 100644 --- a/lib/banzai.rb +++ b/lib/banzai.rb @@ -3,6 +3,10 @@ module Banzai Renderer.render(text, context) end + def self.render_field(object, field) + Renderer.render_field(object, field) + end + def self.cache_collection_render(texts_and_contexts) Renderer.cache_collection_render(texts_and_contexts) end diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index d77a5e3ff09..affe34394c2 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -18,10 +18,6 @@ module Banzai @object_sym ||= object_name.to_sym end - def self.object_class_title - @object_title ||= object_class.name.titleize - end - # Public: Find references in text (like `!123` for merge requests) # # AnyReferenceFilter.references_in(text) do |match, id, project_ref, matches| @@ -49,10 +45,6 @@ module Banzai self.class.object_sym end - def object_class_title - self.class.object_class_title - end - def references_in(*args, &block) self.class.references_in(*args, &block) end @@ -72,7 +64,7 @@ module Banzai end end - def project_from_ref_cache(ref) + def project_from_ref_cached(ref) if RequestStore.active? cache = project_refs_cache @@ -154,7 +146,7 @@ module Banzai # have `gfm` and `gfm-OBJECT_NAME` class names attached for styling. def object_link_filter(text, pattern, link_text: nil) references_in(text, pattern) do |match, id, project_ref, matches| - project = project_from_ref_cache(project_ref) + project = project_from_ref_cached(project_ref) if project && object = find_object_cached(project, id) title = object_link_title(object) @@ -198,7 +190,7 @@ module Banzai end def object_link_title(object) - "#{object_class_title}: #{object.title}" + object.title end def object_link_text(object, matches) @@ -251,11 +243,27 @@ module Banzai end end - # Returns the projects for the given paths. - def find_projects_for_paths(paths) + def projects_relation_for_paths(paths) Project.where_paths_in(paths).includes(:namespace) end + # Returns projects for the given paths. + def find_projects_for_paths(paths) + if RequestStore.active? + to_query = paths - project_refs_cache.keys + + unless to_query.empty? + projects_relation_for_paths(to_query).each do |project| + get_or_set_cache(project_refs_cache, project.path_with_namespace) { project } + end + end + + project_refs_cache.slice(*paths).values + else + projects_relation_for_paths(paths) + end + end + def current_project_path @current_project_path ||= project.path_with_namespace end diff --git a/lib/banzai/filter/commit_range_reference_filter.rb b/lib/banzai/filter/commit_range_reference_filter.rb index bbb88c979cc..4358bf45549 100644 --- a/lib/banzai/filter/commit_range_reference_filter.rb +++ b/lib/banzai/filter/commit_range_reference_filter.rb @@ -35,7 +35,7 @@ module Banzai end def object_link_title(range) - range.reference_title + nil end end end diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb index 2ce1816672b..a26dd09c25a 100644 --- a/lib/banzai/filter/commit_reference_filter.rb +++ b/lib/banzai/filter/commit_reference_filter.rb @@ -28,10 +28,6 @@ module Banzai only_path: context[:only_path]) end - def object_link_title(commit) - commit.link_title - end - def object_link_text_extras(object, matches) extras = super diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb index 2492b5213ac..a8c1ca0c60a 100644 --- a/lib/banzai/filter/emoji_filter.rb +++ b/lib/banzai/filter/emoji_filter.rb @@ -1,6 +1,6 @@ module Banzai module Filter - # HTML filter that replaces :emoji: with images. + # HTML filter that replaces :emoji: and unicode with images. # # Based on HTML::Pipeline::EmojiFilter # @@ -13,16 +13,17 @@ module Banzai def call search_text_nodes(doc).each do |node| content = node.to_html - next unless content.include?(':') next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS) - html = emoji_image_filter(content) + next unless content.include?(':') || node.text.match(emoji_unicode_pattern) + + html = emoji_name_image_filter(content) + html = emoji_unicode_image_filter(html) next if html == content node.replace(html) end - doc end @@ -31,18 +32,38 @@ module Banzai # text - String text to replace :emoji: in. # # Returns a String with :emoji: replaced with images. - def emoji_image_filter(text) + def emoji_name_image_filter(text) text.gsub(emoji_pattern) do |match| name = $1 - "<img class='emoji' title=':#{name}:' alt=':#{name}:' src='#{emoji_url(name)}' height='20' width='20' align='absmiddle' />" + emoji_image_tag(name, emoji_url(name)) end end + # Replace unicode emoji with corresponding images if they exist. + # + # text - String text to replace unicode emoji in. + # + # Returns a String with unicode emoji replaced with images. + def emoji_unicode_image_filter(text) + text.gsub(emoji_unicode_pattern) do |moji| + emoji_image_tag(Gitlab::Emoji.emojis_by_moji[moji]['name'], emoji_unicode_url(moji)) + end + end + + def emoji_image_tag(emoji_name, emoji_url) + "<img class='emoji' title=':#{emoji_name}:' alt=':#{emoji_name}:' src='#{emoji_url}' height='20' width='20' align='absmiddle' />" + end + # Build a regexp that matches all valid :emoji: names. def self.emoji_pattern @emoji_pattern ||= /:(#{Gitlab::Emoji.emojis_names.map { |name| Regexp.escape(name) }.join('|')}):/ end + # Build a regexp that matches all valid unicode emojis names. + def self.emoji_unicode_pattern + @emoji_unicode_pattern ||= /(#{Gitlab::Emoji.emojis_unicodes.map { |moji| Regexp.escape(moji) }.join('|')})/ + end + private def emoji_url(name) @@ -60,6 +81,18 @@ module Banzai end end + def emoji_unicode_url(moji) + emoji_unicode_path = emoji_unicode_filename(moji) + + if context[:asset_host] + url_to_image(emoji_unicode_path) + elsif context[:asset_root] + File.join(context[:asset_root], url_to_image(emoji_unicode_path)) + else + url_to_image(emoji_unicode_path) + end + end + def url_to_image(image) ActionController::Base.helpers.url_to_image(image) end @@ -71,6 +104,14 @@ module Banzai def emoji_filename(name) "#{Gitlab::Emoji.emoji_filename(name)}.png" end + + def emoji_unicode_pattern + self.class.emoji_unicode_pattern + end + + def emoji_unicode_filename(name) + "#{Gitlab::Emoji.emoji_unicode_filename(name)}.png" + end end end end diff --git a/lib/banzai/filter/html_entity_filter.rb b/lib/banzai/filter/html_entity_filter.rb new file mode 100644 index 00000000000..e008fd428b0 --- /dev/null +++ b/lib/banzai/filter/html_entity_filter.rb @@ -0,0 +1,12 @@ +require 'erb' + +module Banzai + module Filter + # Text filter that escapes these HTML entities: & " < > + class HtmlEntityFilter < HTML::Pipeline::TextFilter + def call + ERB::Util.html_escape(text) + end + end + end +end diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb index 4042e9a4c25..54c5f9a71a4 100644 --- a/lib/banzai/filter/issue_reference_filter.rb +++ b/lib/banzai/filter/issue_reference_filter.rb @@ -66,7 +66,7 @@ module Banzai end end - def find_projects_for_paths(paths) + def projects_relation_for_paths(paths) super(paths).includes(:gitlab_issue_tracker_service) end end diff --git a/lib/banzai/filter/label_reference_filter.rb b/lib/banzai/filter/label_reference_filter.rb index e258dc8e2bf..8f262ef3d8d 100644 --- a/lib/banzai/filter/label_reference_filter.rb +++ b/lib/banzai/filter/label_reference_filter.rb @@ -70,6 +70,11 @@ module Banzai def unescape_html_entities(text) CGI.unescapeHTML(text.to_s) end + + def object_link_title(object) + # use title of wrapped element instead + nil + end end end end diff --git a/lib/banzai/filter/milestone_reference_filter.rb b/lib/banzai/filter/milestone_reference_filter.rb index ca686c87d97..58fff496d00 100644 --- a/lib/banzai/filter/milestone_reference_filter.rb +++ b/lib/banzai/filter/milestone_reference_filter.rb @@ -59,6 +59,10 @@ module Banzai html_safe end end + + def object_link_title(object) + nil + end end end end diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index bf058241cda..2d221290f7e 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -52,7 +52,7 @@ module Banzai end def reference_class(type) - "gfm gfm-#{type}" + "gfm gfm-#{type} has-tooltip" end # Ensure that a :project key exists in context diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb index 5b73fc8fcee..4fa8d05481f 100644 --- a/lib/banzai/filter/relative_link_filter.rb +++ b/lib/banzai/filter/relative_link_filter.rb @@ -35,6 +35,7 @@ module Banzai def process_link_attr(html_attr) return if html_attr.blank? + return if html_attr.value.start_with?('//') uri = URI(html_attr.value) if uri.relative? && uri.path.present? @@ -51,7 +52,7 @@ module Banzai relative_url_root, context[:project].path_with_namespace, uri_type(file_path), - ref || context[:project].default_branch, # if no ref exists, point to the default branch + ref, file_path ].compact.join('/').squeeze('/').chomp('/') @@ -92,7 +93,7 @@ module Banzai parts = request_path.split('/') parts.pop if uri_type(request_path) != :tree - path.sub!(%r{^\./}, '') + path.sub!(%r{\A\./}, '') while path.start_with?('../') parts.pop @@ -115,7 +116,7 @@ module Banzai end def current_commit - @current_commit ||= context[:commit] || ref ? repository.commit(ref) : repository.head_commit + @current_commit ||= context[:commit] || repository.commit(ref) end def relative_url_root @@ -123,7 +124,7 @@ module Banzai end def ref - context[:ref] + context[:ref] || context[:project].default_branch end def repository diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index ca80aac5a08..af1e575fc89 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -25,7 +25,7 @@ module Banzai return if customized?(whitelist[:transformers]) # Allow code highlighting - whitelist[:attributes]['pre'] = %w(class) + whitelist[:attributes]['pre'] = %w(class v-pre) whitelist[:attributes]['span'] = %w(class) # Allow table alignment @@ -43,55 +43,57 @@ module Banzai whitelist[:protocols].delete('a') # ...but then remove links with unsafe protocols - whitelist[:transformers].push(remove_unsafe_links) + whitelist[:transformers].push(self.class.remove_unsafe_links) # Remove `rel` attribute from `a` elements - whitelist[:transformers].push(remove_rel) + whitelist[:transformers].push(self.class.remove_rel) # Remove `class` attribute from non-highlight spans - whitelist[:transformers].push(clean_spans) + whitelist[:transformers].push(self.class.clean_spans) whitelist end - def remove_unsafe_links - lambda do |env| - node = env[:node] + class << self + def remove_unsafe_links + lambda do |env| + node = env[:node] - return unless node.name == 'a' - return unless node.has_attribute?('href') + return unless node.name == 'a' + return unless node.has_attribute?('href') - begin - uri = Addressable::URI.parse(node['href']) - uri.scheme = uri.scheme.strip.downcase if uri.scheme + begin + uri = Addressable::URI.parse(node['href']) + uri.scheme = uri.scheme.strip.downcase if uri.scheme - node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme) - rescue Addressable::URI::InvalidURIError - node.remove_attribute('href') + node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme) + rescue Addressable::URI::InvalidURIError + node.remove_attribute('href') + end end end - end - def remove_rel - lambda do |env| - if env[:node_name] == 'a' - env[:node].remove_attribute('rel') + def remove_rel + lambda do |env| + if env[:node_name] == 'a' + env[:node].remove_attribute('rel') + end end end - end - def clean_spans - lambda do |env| - node = env[:node] + def clean_spans + lambda do |env| + node = env[:node] - return unless node.name == 'span' - return unless node.has_attribute?('class') + return unless node.name == 'span' + return unless node.has_attribute?('class') - unless has_ancestor?(node, 'pre') - node.remove_attribute('class') - end + unless node.ancestors.any? { |n| n.name.casecmp('pre').zero? } + node.remove_attribute('class') + end - { node_whitelist: [node] } + { node_whitelist: [node] } + end end end end diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index fcdb496aed2..026b81ac175 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -30,7 +30,7 @@ module Banzai # users can still access an issue/comment/etc. end - highlighted = %(<pre class="#{css_classes}"><code>#{code}</code></pre>) + highlighted = %(<pre class="#{css_classes}" v-pre="true"><code>#{code}</code></pre>) # Extracted to a method to measure it replace_parent_pre_element(node, highlighted) diff --git a/lib/banzai/filter/task_list_filter.rb b/lib/banzai/filter/task_list_filter.rb index 66608c9859c..9fa5f589f3e 100644 --- a/lib/banzai/filter/task_list_filter.rb +++ b/lib/banzai/filter/task_list_filter.rb @@ -2,27 +2,7 @@ require 'task_list/filter' module Banzai module Filter - # Work around a bug in the default TaskList::Filter that adds a `task-list` - # class to every list element, regardless of whether or not it contains a - # task list. - # - # This is a (hopefully) temporary fix, pending a new release of the - # task_list gem. - # - # See https://github.com/github/task_list/pull/60 class TaskListFilter < TaskList::Filter - def add_css_class_with_fix(node, *new_class_names) - if new_class_names.include?('task-list') - # Don't add class to all lists - return - elsif new_class_names.include?('task-list-item') - add_css_class_without_fix(node.parent, 'task-list') - end - - add_css_class_without_fix(node, *new_class_names) - end - - alias_method_chain :add_css_class, :fix end end end diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb index e1ca7f4d24b..c6302b586d3 100644 --- a/lib/banzai/filter/user_reference_filter.rb +++ b/lib/banzai/filter/user_reference_filter.rb @@ -106,13 +106,17 @@ module Banzai project = context[:project] author = context[:author] - url = urls.namespace_project_url(project.namespace, project, - only_path: context[:only_path]) + if author && !project.team.member?(author) + link_text + else + url = urls.namespace_project_url(project.namespace, project, + only_path: context[:only_path]) - data = data_attribute(project: project.id, author: author.try(:id)) - text = link_text || User.reference_prefix + 'all' + data = data_attribute(project: project.id, author: author.try(:id)) + text = link_text || User.reference_prefix + 'all' - link_tag(url, data, text, 'All Project and Group Members') + link_tag(url, data, text, 'All Project and Group Members') + end end def link_to_namespace(namespace, link_text: nil) diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb index fd8b9a6f0cc..ac7bbcb0d10 100644 --- a/lib/banzai/filter/video_link_filter.rb +++ b/lib/banzai/filter/video_link_filter.rb @@ -1,11 +1,9 @@ module Banzai module Filter - # Find every image that isn't already wrapped in an `a` tag, and that has # a `src` attribute ending with a video extension, add a new video node and # a "Download" link in the case the video cannot be played. class VideoLinkFilter < HTML::Pipeline::Filter - def call doc.xpath(query).each do |el| el.replace(video_node(doc, el)) @@ -54,6 +52,5 @@ module Banzai container end end - end end diff --git a/lib/banzai/filter/wiki_link_filter/rewriter.rb b/lib/banzai/filter/wiki_link_filter/rewriter.rb index 2e2c8da311e..e7a1ec8457d 100644 --- a/lib/banzai/filter/wiki_link_filter/rewriter.rb +++ b/lib/banzai/filter/wiki_link_filter/rewriter.rb @@ -31,6 +31,7 @@ module Banzai def apply_relative_link_rules! if @uri.relative? && @uri.path.present? link = ::File.join(@wiki_base_path, @uri.path) + link = "#{link}##{@uri.fragment}" if @uri.fragment @uri = Addressable::URI.parse(link) end end diff --git a/lib/banzai/note_renderer.rb b/lib/banzai/note_renderer.rb index bab6a9934d1..2b7c10f1a0e 100644 --- a/lib/banzai/note_renderer.rb +++ b/lib/banzai/note_renderer.rb @@ -3,7 +3,7 @@ module Banzai # Renders a collection of Note instances. # # notes - The notes to render. - # project - The project to use for rendering/redacting. + # project - The project to use for redacting. # user - The user viewing the notes. # path - The request path. # wiki - The project's wiki. @@ -13,8 +13,7 @@ module Banzai user, requested_path: path, project_wiki: wiki, - ref: git_ref, - pipeline: :note) + ref: git_ref) renderer.render(notes, :note) end diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb index 9aef807c152..9f8eb0931b8 100644 --- a/lib/banzai/object_renderer.rb +++ b/lib/banzai/object_renderer.rb @@ -1,28 +1,32 @@ module Banzai - # Class for rendering multiple objects (e.g. Note instances) in a single pass. + # Class for rendering multiple objects (e.g. Note instances) in a single pass, + # using +render_field+ to benefit from caching in the database. Rendering and + # redaction are both performed. # - # Rendered Markdown is stored in an attribute in every object based on the - # name of the attribute containing the Markdown. For example, when the - # attribute `note` is rendered the HTML is stored in `note_html`. + # The unredacted HTML is generated according to the usual +render_field+ + # policy, so specify the pipeline and any other context options on the model. + # + # The *redacted* (i.e., suitable for use) HTML is placed in an attribute + # named "redacted_<foo>", where <foo> is the name of the cache field for the + # chosen attribute. + # + # As an example, rendering the attribute `note` would place the unredacted + # HTML into `note_html` and the redacted HTML into `redacted_note_html`. class ObjectRenderer attr_reader :project, :user - # Make sure to set the appropriate pipeline in the `raw_context` attribute - # (e.g. `:note` for Note instances). - # - # project - A Project to use for rendering and redacting Markdown. + # project - A Project to use for redacting Markdown. # user - The user viewing the Markdown/HTML documents, if any. - # context - A Hash containing extra attributes to use in the rendering - # pipeline. - def initialize(project, user = nil, raw_context = {}) + # context - A Hash containing extra attributes to use during redaction + def initialize(project, user = nil, redaction_context = {}) @project = project @user = user - @raw_context = raw_context + @redaction_context = redaction_context end # Renders and redacts an Array of objects. # - # objects - The objects to render + # objects - The objects to render. # attribute - The attribute containing the raw Markdown to render. # # Returns the same input objects. @@ -32,7 +36,7 @@ module Banzai objects.each_with_index do |object, index| redacted_data = redacted[index] - object.__send__("#{attribute}_html=", redacted_data[:document].to_html.html_safe) + object.__send__("redacted_#{attribute}_html=", redacted_data[:document].to_html.html_safe) object.user_visible_reference_count = redacted_data[:visible_reference_count] end end @@ -53,12 +57,8 @@ module Banzai # Returns a Banzai context for the given object and attribute. def context_for(object, attribute) - context = base_context.merge(cache_key: [object, attribute]) - - if object.respond_to?(:author) - context[:author] = object.author - end - + context = base_context.dup + context = context.merge(object.banzai_render_context(attribute)) context end @@ -66,21 +66,16 @@ module Banzai # # Returns an Array of `Nokogiri::HTML::Document`. def render_attributes(objects, attribute) - strings_and_contexts = objects.map do |object| + objects.map do |object| + string = Banzai.render_field(object, attribute) context = context_for(object, attribute) - string = object.__send__(attribute) - - { text: string, context: context } - end - - Banzai.cache_collection_render(strings_and_contexts).each_with_index.map do |html, index| - Banzai::Pipeline[:relative_link].to_document(html, strings_and_contexts[index][:context]) + Banzai::Pipeline[:relative_link].to_document(string, context) end end def base_context - @base_context ||= @raw_context.merge(current_user: user, project: project) + @base_context ||= @redaction_context.merge(current_user: user, project: project) end end end diff --git a/lib/banzai/pipeline/single_line_pipeline.rb b/lib/banzai/pipeline/single_line_pipeline.rb index ba2555df98d..1929099931b 100644 --- a/lib/banzai/pipeline/single_line_pipeline.rb +++ b/lib/banzai/pipeline/single_line_pipeline.rb @@ -3,6 +3,7 @@ module Banzai class SingleLinePipeline < GfmPipeline def self.filters @filters ||= FilterArray[ + Filter::HtmlEntityFilter, Filter::SanitizationFilter, Filter::EmojiFilter, diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index 6cf218aaa0d..f5d110e987b 100644 --- a/lib/banzai/reference_parser/base_parser.rb +++ b/lib/banzai/reference_parser/base_parser.rb @@ -79,7 +79,11 @@ module Banzai def referenced_by(nodes) ids = unique_attribute_values(nodes, self.class.data_attribute) - references_relation.where(id: ids) + if ids.empty? + references_relation.none + else + references_relation.where(id: ids) + end end # Returns the ActiveRecord::Relation to use for querying references in the @@ -211,7 +215,7 @@ module Banzai end def can?(user, permission, subject) - Ability.abilities.allowed?(user, permission, subject) + Ability.allowed?(user, permission, subject) end def find_projects_for_hash_keys(hash) diff --git a/lib/banzai/renderer.rb b/lib/banzai/renderer.rb index a4ae27eefd8..6924a293da8 100644 --- a/lib/banzai/renderer.rb +++ b/lib/banzai/renderer.rb @@ -31,6 +31,34 @@ module Banzai end end + # Convert a Markdown-containing field on an object into an HTML-safe String + # of HTML. This method is analogous to calling render(object.field), but it + # can cache the rendered HTML in the object, rather than Redis. + # + # The context to use is learned from the passed-in object by calling + # #banzai_render_context(field), and cannot be changed. Use #render, passing + # it the field text, if a custom rendering is needed. The generated context + # is returned along with the HTML. + def render_field(object, field) + html_field = object.markdown_cache_field_for(field) + + html = object.__send__(html_field) + return html if html.present? + + html = cacheless_render_field(object, field) + object.update_column(html_field, html) unless object.new_record? || object.destroyed? + + html + end + + # Same as +render_field+, but without consulting or updating the cache field + def cacheless_render_field(object, field) + text = object.__send__(field) + context = object.banzai_render_context(field) + + cacheless_render(text, context) + end + # Perform multiple render from an Array of Markdown String into an # Array of HTML-safe String of HTML. # diff --git a/lib/ci/api/api.rb b/lib/ci/api/api.rb index 17bb99a2ae5..a6b9beecded 100644 --- a/lib/ci/api/api.rb +++ b/lib/ci/api/api.rb @@ -9,22 +9,14 @@ module Ci end rescue_from :all do |exception| - # lifted from https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 - # why is this not wrapped in something reusable? - trace = exception.backtrace - - message = "\n#{exception.class} (#{exception.message}):\n" - message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) - message << " " << trace.join("\n ") - - API.logger.add Logger::FATAL, message - rack_response({ 'message' => '500 Internal Server Error' }, 500) + handle_api_exception(exception) end content_type :txt, 'text/plain' content_type :json, 'application/json' format :json + helpers ::SentryHelper helpers ::Ci::API::Helpers helpers ::API::Helpers helpers Gitlab::CurrentSettings diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 260ac81f5fa..ed87a2603e8 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -12,17 +12,21 @@ module Ci # POST /builds/register post "register" do authenticate_runner! - update_runner_last_contact - update_runner_info required_attributes! [:token] not_found! unless current_runner.active? + update_runner_info build = Ci::RegisterBuildService.new.execute(current_runner) if build + Gitlab::Metrics.add_event(:build_found, + project: build.project.path_with_namespace) + present build, with: Entities::BuildDetails else - not_found! + Gitlab::Metrics.add_event(:build_not_found) + + build_not_found! end end @@ -36,12 +40,16 @@ module Ci # PUT /builds/:id put ":id" do authenticate_runner! - update_runner_last_contact build = Ci::Build.where(runner_id: current_runner.id).running.find(params[:id]) forbidden!('Build has been erased!') if build.erased? + update_runner_info + build.update_attributes(trace: params[:trace]) if params[:trace] + Gitlab::Metrics.add_event(:update_build, + project: build.project.path_with_namespace) + case params[:state].to_s when 'success' build.success @@ -93,6 +101,7 @@ module Ci # POST /builds/:id/artifacts/authorize post ":id/artifacts/authorize" do require_gitlab_workhorse! + Gitlab::Workhorse.verify_api_request!(headers) not_allowed! unless Gitlab.config.artifacts.enabled build = Ci::Build.find_by_id(params[:id]) not_found! unless build @@ -105,7 +114,8 @@ module Ci end status 200 - { TempPath: ArtifactUploader.artifacts_upload_path } + content_type Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE + Gitlab::Workhorse.artifact_upload_ok end # Upload artifacts to build - Runners only diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb index 3f5bdaba3f5..66c05773b68 100644 --- a/lib/ci/api/entities.rb +++ b/lib/ci/api/entities.rb @@ -15,6 +15,15 @@ module Ci expose :filename, :size end + class BuildOptions < Grape::Entity + expose :image + expose :services + expose :artifacts + expose :cache + expose :dependencies + expose :after_script + end + class Build < Grape::Entity expose :id, :ref, :tag, :sha, :status expose :name, :token, :stage diff --git a/lib/ci/api/helpers.rb b/lib/ci/api/helpers.rb index 199d62d9b8a..e608f5f6cad 100644 --- a/lib/ci/api/helpers.rb +++ b/lib/ci/api/helpers.rb @@ -3,7 +3,7 @@ module Ci module Helpers BUILD_TOKEN_HEADER = "HTTP_BUILD_TOKEN" BUILD_TOKEN_PARAM = :token - UPDATE_RUNNER_EVERY = 60 + UPDATE_RUNNER_EVERY = 10 * 60 def authenticate_runners! forbidden! unless runner_registration_token_valid? @@ -14,19 +14,45 @@ module Ci end def authenticate_build_token!(build) - token = (params[BUILD_TOKEN_PARAM] || env[BUILD_TOKEN_HEADER]).to_s - forbidden! unless token && build.valid_token?(token) + forbidden! unless build_token_valid?(build) end def runner_registration_token_valid? - params[:token] == current_application_settings.runners_registration_token + ActiveSupport::SecurityUtils.variable_size_secure_compare( + params[:token], + current_application_settings.runners_registration_token) + end + + def build_token_valid?(build) + token = (params[BUILD_TOKEN_PARAM] || env[BUILD_TOKEN_HEADER]).to_s + + # We require to also check `runners_token` to maintain compatibility with old version of runners + token && (build.valid_token?(token) || build.project.valid_runners_token?(token)) end - def update_runner_last_contact - # Use a random threshold to prevent beating DB updates + def update_runner_info + return unless update_runner? + + current_runner.contacted_at = Time.now + current_runner.assign_attributes(get_runner_version_from_params) + current_runner.save if current_runner.changed? + end + + def update_runner? + # Use a random threshold to prevent beating DB updates. + # It generates a distribution between [40m, 80m]. + # contacted_at_max_age = UPDATE_RUNNER_EVERY + Random.rand(UPDATE_RUNNER_EVERY) - if current_runner.contacted_at.nil? || Time.now - current_runner.contacted_at >= contacted_at_max_age - current_runner.update_attributes(contacted_at: Time.now) + + current_runner.contacted_at.nil? || + (Time.now - current_runner.contacted_at) >= contacted_at_max_age + end + + def build_not_found! + if headers['User-Agent'].match(/gitlab-ci-multi-runner \d+\.\d+\.\d+(~beta\.\d+\.g[0-9a-f]+)? /) + no_content! + else + not_found! end end @@ -39,11 +65,6 @@ module Ci attributes_for_keys(["name", "version", "revision", "platform", "architecture"], params["info"]) end - def update_runner_info - current_runner.assign_attributes(get_runner_version_from_params) - current_runner.save if current_runner.changed? - end - def max_artifacts_size current_application_settings.max_artifacts_size.megabytes.to_i end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index a2e8bd22a52..2fd1fced65c 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -4,7 +4,7 @@ module Ci include Gitlab::Ci::Config::Node::LegacyValidationHelpers - attr_reader :path, :cache, :stages + attr_reader :path, :cache, :stages, :jobs def initialize(config, path = nil) @ci_config = Gitlab::Ci::Config.new(config) @@ -55,29 +55,36 @@ module Ci { stage_idx: @stages.index(job[:stage]), stage: job[:stage], - ## - # Refactoring note: - # - before script behaves differently than after script - # - after script returns an array of commands - # - before script should be a concatenated command - commands: [job[:before_script] || @before_script, job[:script]].flatten.compact.join("\n"), + commands: job[:commands], tag_list: job[:tags] || [], - name: job[:name], + name: job[:name].to_s, allow_failure: job[:allow_failure] || false, when: job[:when] || 'on_success', - environment: job[:environment], + environment: job[:environment_name], yaml_variables: yaml_variables(name), options: { - image: job[:image] || @image, - services: job[:services] || @services, + image: job[:image], + services: job[:services], artifacts: job[:artifacts], - cache: job[:cache] || @cache, + cache: job[:cache], dependencies: job[:dependencies], - after_script: job[:after_script] || @after_script, + after_script: job[:after_script], + environment: job[:environment], }.compact } end + def self.validation_message(content) + return 'Please provide content of .gitlab-ci.yml' if content.blank? + + begin + Ci::GitlabCiYamlProcessor.new(content) + nil + rescue ValidationError, Psych::SyntaxError => e + e.message + end + end + private def initial_parsing diff --git a/lib/ci/mask_secret.rb b/lib/ci/mask_secret.rb new file mode 100644 index 00000000000..997377abc55 --- /dev/null +++ b/lib/ci/mask_secret.rb @@ -0,0 +1,10 @@ +module Ci::MaskSecret + class << self + def mask!(value, token) + return value unless value.present? && token.present? + + value.gsub!(token, 'x' * token.length) + value + end + end +end diff --git a/lib/ci/static_model.rb b/lib/ci/static_model.rb deleted file mode 100644 index bb2bdbed495..00000000000 --- a/lib/ci/static_model.rb +++ /dev/null @@ -1,49 +0,0 @@ -# Provides an ActiveRecord-like interface to a model whose data is not persisted to a database. -module Ci - module StaticModel - extend ActiveSupport::Concern - - module ClassMethods - # Used by ActiveRecord's polymorphic association to set object_id - def primary_key - 'id' - end - - # Used by ActiveRecord's polymorphic association to set object_type - def base_class - self - end - end - - # Used by AR for fetching attributes - # - # Pass it along if we respond to it. - def [](key) - send(key) if respond_to?(key) - end - - def to_param - id - end - - def new_record? - false - end - - def persisted? - false - end - - def destroyed? - false - end - - def ==(other) - if other.is_a? ::Ci::StaticModel - id == other.id - else - super - end - end - end -end diff --git a/lib/ci/version_info.rb b/lib/ci/version_info.rb deleted file mode 100644 index 2a87c91db5e..00000000000 --- a/lib/ci/version_info.rb +++ /dev/null @@ -1,52 +0,0 @@ -class VersionInfo - include Comparable - - attr_reader :major, :minor, :patch - - def self.parse(str) - if str && m = str.match(/(\d+)\.(\d+)\.(\d+)/) - VersionInfo.new(m[1].to_i, m[2].to_i, m[3].to_i) - else - VersionInfo.new - end - end - - def initialize(major = 0, minor = 0, patch = 0) - @major = major - @minor = minor - @patch = patch - end - - def <=>(other) - return unless other.is_a? VersionInfo - return unless valid? && other.valid? - - if other.major < @major - 1 - elsif @major < other.major - -1 - elsif other.minor < @minor - 1 - elsif @minor < other.minor - -1 - elsif other.patch < @patch - 1 - elsif @patch < other.patch - -1 - else - 0 - end - end - - def to_s - if valid? - "%d.%d.%d" % [@major, @minor, @patch] - else - "Unknown" - end - end - - def valid? - @major >= 0 && @minor >= 0 && @patch >= 0 && @major + @minor + @patch > 0 - end -end diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb new file mode 100644 index 00000000000..ca39b1961ae --- /dev/null +++ b/lib/constraints/group_url_constrainer.rb @@ -0,0 +1,7 @@ +require 'constraints/namespace_url_constrainer' + +class GroupUrlConstrainer < NamespaceUrlConstrainer + def find_resource(id) + Group.find_by_path(id) + end +end diff --git a/lib/constraints/namespace_url_constrainer.rb b/lib/constraints/namespace_url_constrainer.rb new file mode 100644 index 00000000000..23920193743 --- /dev/null +++ b/lib/constraints/namespace_url_constrainer.rb @@ -0,0 +1,13 @@ +class NamespaceUrlConstrainer + def matches?(request) + id = request.path.sub(/\A\/+/, '').split('/').first.sub(/.atom\z/, '') + + if id =~ Gitlab::Regex.namespace_regex + find_resource(id) + end + end + + def find_resource(id) + Namespace.find_by_path(id) + end +end diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb new file mode 100644 index 00000000000..504a0f5d93e --- /dev/null +++ b/lib/constraints/user_url_constrainer.rb @@ -0,0 +1,7 @@ +require 'constraints/namespace_url_constrainer' + +class UserUrlConstrainer < NamespaceUrlConstrainer + def find_resource(id) + User.find_by('lower(username) = ?', id.downcase) + end +end diff --git a/lib/event_filter.rb b/lib/event_filter.rb index 668d2fa41b3..96e70e37e8f 100644 --- a/lib/event_filter.rb +++ b/lib/event_filter.rb @@ -2,8 +2,8 @@ class EventFilter attr_accessor :params class << self - def default_filter - %w{ push issues merge_requests team} + def all + 'all' end def push @@ -35,18 +35,21 @@ class EventFilter return events unless params.present? filter = params.dup - actions = [] - actions << Event::PUSHED if filter.include? 'push' - actions << Event::MERGED if filter.include? 'merged' - if filter.include? 'team' - actions << Event::JOINED - actions << Event::LEFT + case filter + when EventFilter.push + actions = [Event::PUSHED] + when EventFilter.merged + actions = [Event::MERGED] + when EventFilter.comments + actions = [Event::COMMENTED] + when EventFilter.team + actions = [Event::JOINED, Event::LEFT] + when EventFilter.all + actions = [Event::PUSHED, Event::MERGED, Event::COMMENTED, Event::JOINED, Event::LEFT] end - actions << Event::COMMENTED if filter.include? 'comments' - events.where(action: actions) end diff --git a/lib/expand_variables.rb b/lib/expand_variables.rb new file mode 100644 index 00000000000..7b1533d0d32 --- /dev/null +++ b/lib/expand_variables.rb @@ -0,0 +1,17 @@ +module ExpandVariables + class << self + def expand(value, variables) + # Convert hash array to variables + if variables.is_a?(Array) + variables = variables.reduce({}) do |hash, variable| + hash[variable[:key]] = variable[:value] + hash + end + end + + value.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}|%\g<1>%/) do + variables[$1 || $2] + end + end + end +end diff --git a/lib/extracts_path.rb b/lib/extracts_path.rb index 51e46da82cc..e4d996a3fb6 100644 --- a/lib/extracts_path.rb +++ b/lib/extracts_path.rb @@ -52,8 +52,7 @@ module ExtractsPath # Append a trailing slash if we only get a ref and no file path id += '/' unless id.ends_with?('/') - valid_refs = @project.repository.ref_names - valid_refs.select! { |v| id.start_with?("#{v}/") } + valid_refs = ref_names.select { |v| id.start_with?("#{v}/") } if valid_refs.length == 0 # No exact ref match, so just try our best @@ -74,6 +73,19 @@ module ExtractsPath pair end + # If we have an ID of 'foo.atom', and the controller provides Atom and HTML + # formats, then we have to check if the request was for the Atom version of + # the ID without the '.atom' suffix, or the HTML version of the ID including + # the suffix. We only check this if the version including the suffix doesn't + # match, so it is possible to create a branch which has an unroutable Atom + # feed. + def extract_ref_without_atom(id) + id_without_atom = id.sub(/\.atom$/, '') + valid_refs = ref_names.select { |v| "#{id_without_atom}/".start_with?("#{v}/") } + + valid_refs.max_by(&:length) + end + # Assigns common instance variables for views working with Git tree-ish objects # # Assignments are: @@ -86,6 +98,10 @@ module ExtractsPath # If the :id parameter appears to be requesting a specific response format, # that will be handled as well. # + # If there is no path and the ref doesn't exist in the repo, try to resolve + # the ref without an '.atom' suffix. If _that_ ref is found, set the request's + # format to Atom manually. + # # Automatically renders `not_found!` if a valid tree path could not be # resolved (e.g., when a user inserts an invalid path or ref). def assign_ref_vars @@ -94,7 +110,7 @@ module ExtractsPath @options = params.select {|key, value| allowed_options.include?(key) && !value.blank? } @options = HashWithIndifferentAccess.new(@options) - @id = Addressable::URI.unescape(get_id) + @id = get_id @ref, @path = extract_ref(@id) @repo = @project.repository if @options[:extended_sha1].blank? @@ -103,6 +119,13 @@ module ExtractsPath @commit = @repo.commit(@options[:extended_sha1]) end + if @path.empty? && !@commit + @id = @ref = extract_ref_without_atom(@id) + @commit = @repo.commit(@ref) + + request.format = :atom if @commit + end + raise InvalidPathError unless @commit @hex_path = Digest::SHA1.hexdigest(@path) @@ -119,9 +142,16 @@ module ExtractsPath private + # overriden in subclasses, do not remove def get_id id = params[:id] || params[:ref] id += "/" + params[:path] unless params[:path].blank? id end + + def ref_names + return [] unless @project + + @ref_names ||= @project.repository.ref_names + end end diff --git a/lib/gitlab/access.rb b/lib/gitlab/access.rb index a533bac2692..9b484a2ecfd 100644 --- a/lib/gitlab/access.rb +++ b/lib/gitlab/access.rb @@ -53,6 +53,10 @@ module Gitlab } end + def sym_options_with_owner + sym_options.merge(owner: OWNER) + end + def protection_options { "Not protected: Both developers and masters can push new commits, force push, or delete the branch." => PROTECTION_NONE, diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb deleted file mode 100644 index 207736b59db..00000000000 --- a/lib/gitlab/akismet_helper.rb +++ /dev/null @@ -1,47 +0,0 @@ -module Gitlab - module AkismetHelper - def akismet_enabled? - current_application_settings.akismet_enabled - end - - def akismet_client - @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, - Gitlab.config.gitlab.url) - end - - def client_ip(env) - env['action_dispatch.remote_ip'].to_s - end - - def user_agent(env) - env['HTTP_USER_AGENT'] - end - - def check_for_spam?(project) - akismet_enabled? && project.public? - end - - def is_spam?(environment, user, text) - client = akismet_client - ip_address = client_ip(environment) - user_agent = user_agent(environment) - - params = { - type: 'comment', - text: text, - created_at: DateTime.now, - author: user.name, - author_email: user.email, - referrer: environment['HTTP_REFERER'], - } - - begin - is_spam, is_blatant = client.check(ip_address, user_agent, params) - is_spam || is_blatant - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") - false - end - end - end -end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index db1704af75e..aca5d0020cf 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -1,22 +1,22 @@ module Gitlab module Auth - Result = Struct.new(:user, :type) + class MissingPersonalTokenError < StandardError; end class << self def find_for_git_client(login, password, project:, ip:) raise "Must provide an IP for rate limiting" if ip.nil? - result = Result.new + result = + service_request_check(login, password, project) || + build_access_token_check(login, password) || + user_with_password_for_git(login, password) || + oauth_access_token_check(login, password) || + lfs_token_check(login, password) || + personal_access_token_check(login, password) || + Gitlab::Auth::Result.new - if valid_ci_request?(login, password, project) - result.type = :ci - elsif result.user = find_with_user_password(login, password) - result.type = :gitlab_or_ldap - elsif result.user = oauth_access_token_check(login, password) - result.type = :oauth - end + rate_limit!(ip, success: result.success?, login: login) - rate_limit!(ip, success: !!result.user || (result.type == :ci), login: login) result end @@ -58,30 +58,117 @@ module Gitlab private - def valid_ci_request?(login, password, project) + def service_request_check(login, password, project) matched_login = /(?<service>^[a-zA-Z]*-ci)-token$/.match(login) - return false unless project && matched_login.present? + return unless project && matched_login.present? underscored_service = matched_login['service'].underscore - if underscored_service == 'gitlab_ci' - project && project.valid_build_token?(password) - elsif Service.available_services_names.include?(underscored_service) + if Service.available_services_names.include?(underscored_service) # We treat underscored_service as a trusted input because it is included # in the Service.available_services_names whitelist. service = project.public_send("#{underscored_service}_service") - service && service.activated? && service.valid_token?(password) + if service && service.activated? && service.valid_token?(password) + Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities) + end end end + def user_with_password_for_git(login, password) + user = find_with_user_password(login, password) + return unless user + + raise Gitlab::Auth::MissingPersonalTokenError if user.two_factor_enabled? + + Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities) + end + def oauth_access_token_check(login, password) if login == "oauth2" && password.present? token = Doorkeeper::AccessToken.by_token(password) - token && token.accessible? && User.find_by(id: token.resource_owner_id) + if token && token.accessible? + user = User.find_by(id: token.resource_owner_id) + Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities) + end + end + end + + def personal_access_token_check(login, password) + if login && password + user = User.find_by_personal_access_token(password) + validation = User.by_login(login) + Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities) if user.present? && user == validation + end + end + + def lfs_token_check(login, password) + deploy_key_matches = login.match(/\Alfs\+deploy-key-(\d+)\z/) + + actor = + if deploy_key_matches + DeployKey.find(deploy_key_matches[1]) + else + User.by_login(login) + end + + return unless actor + + token_handler = Gitlab::LfsToken.new(actor) + + authentication_abilities = + if token_handler.user? + full_authentication_abilities + else + read_authentication_abilities + end + + Result.new(actor, nil, token_handler.type, authentication_abilities) if Devise.secure_compare(token_handler.token, password) + end + + def build_access_token_check(login, password) + return unless login == 'gitlab-ci-token' + return unless password + + build = ::Ci::Build.running.find_by_token(password) + return unless build + return unless build.project.builds_enabled? + + if build.user + # If user is assigned to build, use restricted credentials of user + Gitlab::Auth::Result.new(build.user, build.project, :build, build_authentication_abilities) + else + # Otherwise use generic CI credentials (backward compatibility) + Gitlab::Auth::Result.new(nil, build.project, :ci, build_authentication_abilities) end end + + public + + def build_authentication_abilities + [ + :read_project, + :build_download_code, + :build_read_container_image, + :build_create_container_image + ] + end + + def read_authentication_abilities + [ + :read_project, + :download_code, + :read_container_image + ] + end + + def full_authentication_abilities + read_authentication_abilities + [ + :push_code, + :create_container_image + ] + end end end end diff --git a/lib/gitlab/auth/result.rb b/lib/gitlab/auth/result.rb new file mode 100644 index 00000000000..6be7f690676 --- /dev/null +++ b/lib/gitlab/auth/result.rb @@ -0,0 +1,21 @@ +module Gitlab + module Auth + Result = Struct.new(:actor, :project, :type, :authentication_abilities) do + def ci?(for_project) + type == :ci && + project && + project == for_project + end + + def lfs_deploy_token?(for_project) + type == :lfs_deploy_token && + actor && + actor.projects.include?(for_project) + end + + def success? + actor.present? || type == :ci + end + end + end +end diff --git a/lib/gitlab/backend/grack_auth.rb b/lib/gitlab/backend/grack_auth.rb deleted file mode 100644 index ab94abeda77..00000000000 --- a/lib/gitlab/backend/grack_auth.rb +++ /dev/null @@ -1,163 +0,0 @@ -module Grack - class AuthSpawner - def self.call(env) - # Avoid issues with instance variables in Grack::Auth persisting across - # requests by creating a new instance for each request. - Auth.new({}).call(env) - end - end - - class Auth < Rack::Auth::Basic - attr_accessor :user, :project, :env - - def call(env) - @env = env - @request = Rack::Request.new(env) - @auth = Request.new(env) - - @ci = false - - # Need this patch due to the rails mount - # Need this if under RELATIVE_URL_ROOT - unless Gitlab.config.gitlab.relative_url_root.empty? - # If website is mounted using relative_url_root need to remove it first - @env['PATH_INFO'] = @request.path.sub(Gitlab.config.gitlab.relative_url_root, '') - else - @env['PATH_INFO'] = @request.path - end - - @env['SCRIPT_NAME'] = "" - - auth! - - lfs_response = Gitlab::Lfs::Router.new(project, @user, @ci, @request).try_call - return lfs_response unless lfs_response.nil? - - if @user.nil? && !@ci - unauthorized - else - render_not_found - end - end - - private - - def auth! - return unless @auth.provided? - - return bad_request unless @auth.basic? - - # Authentication with username and password - login, password = @auth.credentials - - # Allow authentication for GitLab CI service - # if valid token passed - if ci_request?(login, password) - @ci = true - return - end - - @user = authenticate_user(login, password) - end - - def ci_request?(login, password) - matched_login = /(?<s>^[a-zA-Z]*-ci)-token$/.match(login) - - if project && matched_login.present? - underscored_service = matched_login['s'].underscore - - if underscored_service == 'gitlab_ci' - return project && project.valid_build_token?(password) - elsif Service.available_services_names.include?(underscored_service) - service_method = "#{underscored_service}_service" - service = project.send(service_method) - - return service && service.activated? && service.valid_token?(password) - end - end - - false - end - - def oauth_access_token_check(login, password) - if login == "oauth2" && git_cmd == 'git-upload-pack' && password.present? - token = Doorkeeper::AccessToken.by_token(password) - token && token.accessible? && User.find_by(id: token.resource_owner_id) - end - end - - def authenticate_user(login, password) - user = Gitlab::Auth.find_with_user_password(login, password) - - unless user - user = oauth_access_token_check(login, password) - end - - # If the user authenticated successfully, we reset the auth failure count - # from Rack::Attack for that IP. A client may attempt to authenticate - # with a username and blank password first, and only after it receives - # a 401 error does it present a password. Resetting the count prevents - # false positives from occurring. - # - # Otherwise, we let Rack::Attack know there was a failed authentication - # attempt from this IP. This information is stored in the Rails cache - # (Redis) and will be used by the Rack::Attack middleware to decide - # whether to block requests from this IP. - config = Gitlab.config.rack_attack.git_basic_auth - - if config.enabled - if user - # A successful login will reset the auth failure count from this IP - Rack::Attack::Allow2Ban.reset(@request.ip, config) - else - banned = Rack::Attack::Allow2Ban.filter(@request.ip, config) do - # Unless the IP is whitelisted, return true so that Allow2Ban - # increments the counter (stored in Rails.cache) for the IP - if config.ip_whitelist.include?(@request.ip) - false - else - true - end - end - - if banned - Rails.logger.info "IP #{@request.ip} failed to login " \ - "as #{login} but has been temporarily banned from Git auth" - end - end - end - - user - end - - def git_cmd - if @request.get? - @request.params['service'] - elsif @request.post? - File.basename(@request.path) - else - nil - end - end - - def project - return @project if defined?(@project) - - @project = project_by_path(@request.path_info) - end - - def project_by_path(path) - if m = /^([\w\.\/-]+)\.git/.match(path).to_a - path_with_namespace = m.last - path_with_namespace.gsub!(/\.wiki$/, '') - - path_with_namespace[0] = '' if path_with_namespace.start_with?('/') - Project.find_with_namespace(path_with_namespace) - end - end - - def render_not_found - [404, { "Content-Type" => "text/plain" }, ["Not Found"]] - end - end -end diff --git a/lib/gitlab/backend/shell.rb b/lib/gitlab/backend/shell.rb index 839a4fa30d5..9cec71a3222 100644 --- a/lib/gitlab/backend/shell.rb +++ b/lib/gitlab/backend/shell.rb @@ -6,16 +6,56 @@ module Gitlab KeyAdder = Struct.new(:io) do def add_key(id, key) - key.gsub!(/[[:space:]]+/, ' ').strip! + key = Gitlab::Shell.strip_key(key) + # Newline and tab are part of the 'protocol' used to transmit id+key to the other end + if key.include?("\t") || key.include?("\n") + raise Error.new("Invalid key: #{key.inspect}") + end + io.puts("#{id}\t#{key}") end end class << self + def secret_token + @secret_token ||= begin + File.read(Gitlab.config.gitlab_shell.secret_file).chomp + end + end + + def ensure_secret_token! + return if File.exist?(File.join(Gitlab.config.gitlab_shell.path, '.gitlab_shell_secret')) + + generate_and_link_secret_token + end + def version_required @version_required ||= File.read(Rails.root. join('GITLAB_SHELL_VERSION')).strip end + + def strip_key(key) + key.split(/ /)[0, 2].join(' ') + end + + private + + # Create (if necessary) and link the secret token file + def generate_and_link_secret_token + secret_file = Gitlab.config.gitlab_shell.secret_file + shell_path = Gitlab.config.gitlab_shell.path + + unless File.size?(secret_file) + # Generate a new token of 16 random hexadecimal characters and store it in secret_file. + @secret_token = SecureRandom.hex(16) + File.write(secret_file, @secret_token) + end + + link_path = File.join(shell_path, '.gitlab_shell_secret') + if File.exist?(shell_path) && !File.exist?(link_path) + FileUtils.symlink(secret_file, link_path) + end + end end # Init new repository @@ -107,7 +147,7 @@ module Gitlab # def add_key(key_id, key_content) Gitlab::Utils.system_silent([gitlab_shell_keys_path, - 'add-key', key_id, key_content]) + 'add-key', key_id, self.class.strip_key(key_content)]) end # Batch-add keys to authorized_keys @@ -192,21 +232,6 @@ module Gitlab File.exist?(full_path(storage, dir_name)) end - # Create (if necessary) and link the secret token file - def generate_and_link_secret_token - secret_file = Gitlab.config.gitlab_shell.secret_file - unless File.exist? secret_file - # Generate a new token of 16 random hexadecimal characters and store it in secret_file. - token = SecureRandom.hex(16) - File.write(secret_file, token) - end - - link_path = File.join(gitlab_shell_path, '.gitlab_shell_secret') - if File.exist?(gitlab_shell_path) && !File.exist?(link_path) - FileUtils.symlink(secret_file, link_path) - end - end - protected def gitlab_shell_path diff --git a/lib/gitlab/badge/base.rb b/lib/gitlab/badge/base.rb new file mode 100644 index 00000000000..909fa24fa90 --- /dev/null +++ b/lib/gitlab/badge/base.rb @@ -0,0 +1,21 @@ +module Gitlab + module Badge + class Base + def entity + raise NotImplementedError + end + + def status + raise NotImplementedError + end + + def metadata + raise NotImplementedError + end + + def template + raise NotImplementedError + end + end + end +end diff --git a/lib/gitlab/badge/build.rb b/lib/gitlab/badge/build.rb deleted file mode 100644 index e5e9fab3f5c..00000000000 --- a/lib/gitlab/badge/build.rb +++ /dev/null @@ -1,46 +0,0 @@ -module Gitlab - module Badge - ## - # Build badge - # - class Build - include Gitlab::Application.routes.url_helpers - include ActionView::Helpers::AssetTagHelper - include ActionView::Helpers::UrlHelper - - def initialize(project, ref) - @project, @ref = project, ref - @image = ::Ci::ImageForBuildService.new.execute(project, ref: ref) - end - - def type - 'image/svg+xml' - end - - def data - File.read(@image[:path]) - end - - def to_s - @image[:name].sub(/\.svg$/, '') - end - - def to_html - link_to(image_tag(image_url, alt: 'build status'), link_url) - end - - def to_markdown - "[](#{link_url})" - end - - def image_url - build_namespace_project_badges_url(@project.namespace, - @project, @ref, format: :svg) - end - - def link_url - namespace_project_commits_url(@project.namespace, @project, id: @ref) - end - end - end -end diff --git a/lib/gitlab/badge/build/metadata.rb b/lib/gitlab/badge/build/metadata.rb new file mode 100644 index 00000000000..f87a7b7942e --- /dev/null +++ b/lib/gitlab/badge/build/metadata.rb @@ -0,0 +1,28 @@ +module Gitlab + module Badge + module Build + ## + # Class that describes build badge metadata + # + class Metadata < Badge::Metadata + def initialize(badge) + @project = badge.project + @ref = badge.ref + end + + def title + 'build status' + end + + def image_url + build_namespace_project_badges_url(@project.namespace, + @project, @ref, format: :svg) + end + + def link_url + namespace_project_commits_url(@project.namespace, @project, id: @ref) + end + end + end + end +end diff --git a/lib/gitlab/badge/build/status.rb b/lib/gitlab/badge/build/status.rb new file mode 100644 index 00000000000..50aa45e5406 --- /dev/null +++ b/lib/gitlab/badge/build/status.rb @@ -0,0 +1,37 @@ +module Gitlab + module Badge + module Build + ## + # Build status badge + # + class Status < Badge::Base + attr_reader :project, :ref + + def initialize(project, ref) + @project = project + @ref = ref + + @sha = @project.commit(@ref).try(:sha) + end + + def entity + 'build' + end + + def status + @project.pipelines + .where(sha: @sha, ref: @ref) + .status || 'unknown' + end + + def metadata + @metadata ||= Build::Metadata.new(self) + end + + def template + @template ||= Build::Template.new(self) + end + end + end + end +end diff --git a/lib/gitlab/badge/build/template.rb b/lib/gitlab/badge/build/template.rb new file mode 100644 index 00000000000..2b95ddfcb53 --- /dev/null +++ b/lib/gitlab/badge/build/template.rb @@ -0,0 +1,47 @@ +module Gitlab + module Badge + module Build + ## + # Class that represents a build badge template. + # + # Template object will be passed to badge.svg.erb template. + # + class Template < Badge::Template + STATUS_COLOR = { + success: '#4c1', + failed: '#e05d44', + running: '#dfb317', + pending: '#dfb317', + canceled: '#9f9f9f', + skipped: '#9f9f9f', + unknown: '#9f9f9f' + } + + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + @entity.to_s + end + + def value_text + @status.to_s + end + + def key_width + 38 + end + + def value_width + 54 + end + + def value_color + STATUS_COLOR[@status.to_sym] || STATUS_COLOR[:unknown] + end + end + end + end +end diff --git a/lib/gitlab/badge/coverage/metadata.rb b/lib/gitlab/badge/coverage/metadata.rb new file mode 100644 index 00000000000..53588185622 --- /dev/null +++ b/lib/gitlab/badge/coverage/metadata.rb @@ -0,0 +1,30 @@ +module Gitlab + module Badge + module Coverage + ## + # Class that describes coverage badge metadata + # + class Metadata < Badge::Metadata + def initialize(badge) + @project = badge.project + @ref = badge.ref + @job = badge.job + end + + def title + 'coverage report' + end + + def image_url + coverage_namespace_project_badges_url(@project.namespace, + @project, @ref, + format: :svg) + end + + def link_url + namespace_project_commits_url(@project.namespace, @project, id: @ref) + end + end + end + end +end diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb new file mode 100644 index 00000000000..9a0482306b7 --- /dev/null +++ b/lib/gitlab/badge/coverage/report.rb @@ -0,0 +1,53 @@ +module Gitlab + module Badge + module Coverage + ## + # Test coverage report badge + # + class Report < Badge::Base + attr_reader :project, :ref, :job + + def initialize(project, ref, job = nil) + @project = project + @ref = ref + @job = job + + @pipeline = @project.pipelines.latest_successful_for(@ref) + end + + def entity + 'coverage' + end + + def status + @coverage ||= raw_coverage + return unless @coverage + + @coverage.to_i + end + + def metadata + @metadata ||= Coverage::Metadata.new(self) + end + + def template + @template ||= Coverage::Template.new(self) + end + + private + + def raw_coverage + return unless @pipeline + + if @job.blank? + @pipeline.coverage + else + @pipeline.builds + .find_by(name: @job) + .try(:coverage) + end + end + end + end + end +end diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb new file mode 100644 index 00000000000..06e0d084e9f --- /dev/null +++ b/lib/gitlab/badge/coverage/template.rb @@ -0,0 +1,52 @@ +module Gitlab + module Badge + module Coverage + ## + # Class that represents a coverage badge template. + # + # Template object will be passed to badge.svg.erb template. + # + class Template < Badge::Template + STATUS_COLOR = { + good: '#4c1', + acceptable: '#a3c51c', + medium: '#dfb317', + low: '#e05d44', + unknown: '#9f9f9f' + } + + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + @entity.to_s + end + + def value_text + @status ? "#{@status}%" : 'unknown' + end + + def key_width + 62 + end + + def value_width + @status ? 36 : 58 + end + + def value_color + case @status + when 95..100 then STATUS_COLOR[:good] + when 90..95 then STATUS_COLOR[:acceptable] + when 75..90 then STATUS_COLOR[:medium] + when 0..75 then STATUS_COLOR[:low] + else + STATUS_COLOR[:unknown] + end + end + end + end + end +end diff --git a/lib/gitlab/badge/metadata.rb b/lib/gitlab/badge/metadata.rb new file mode 100644 index 00000000000..548f85b78bb --- /dev/null +++ b/lib/gitlab/badge/metadata.rb @@ -0,0 +1,36 @@ +module Gitlab + module Badge + ## + # Abstract class for badge metadata + # + class Metadata + include Gitlab::Application.routes.url_helpers + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::UrlHelper + + def initialize(badge) + @badge = badge + end + + def to_html + link_to(image_tag(image_url, alt: title), link_url) + end + + def to_markdown + "[](#{link_url})" + end + + def title + raise NotImplementedError + end + + def image_url + raise NotImplementedError + end + + def link_url + raise NotImplementedError + end + end + end +end diff --git a/lib/gitlab/badge/template.rb b/lib/gitlab/badge/template.rb new file mode 100644 index 00000000000..bfeb0052642 --- /dev/null +++ b/lib/gitlab/badge/template.rb @@ -0,0 +1,49 @@ +module Gitlab + module Badge + ## + # Abstract template class for badges + # + class Template + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + raise NotImplementedError + end + + def value_text + raise NotImplementedError + end + + def key_width + raise NotImplementedError + end + + def value_width + raise NotImplementedError + end + + def value_color + raise NotImplementedError + end + + def key_color + '#555' + end + + def key_text_anchor + key_width / 2 + end + + def value_text_anchor + key_width + (value_width / 2) + end + + def width + key_width + value_width + end + end + end +end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 7beaecd1cf0..f4b5097adb1 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -21,7 +21,7 @@ module Gitlab private - def gl_user_id(project, bitbucket_id) + def gitlab_user_id(project, bitbucket_id) if bitbucket_id user = User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", bitbucket_id.to_s) (user && user.id) || project.creator_id @@ -74,7 +74,7 @@ module Gitlab description: body, title: issue["title"], state: %w(resolved invalid duplicate wontfix closed).include?(issue["status"]) ? 'closed' : 'opened', - author_id: gl_user_id(project, reporter) + author_id: gitlab_user_id(project, reporter) ) end rescue ActiveRecord::RecordInvalid => e diff --git a/lib/gitlab/changes_list.rb b/lib/gitlab/changes_list.rb new file mode 100644 index 00000000000..95308aca95f --- /dev/null +++ b/lib/gitlab/changes_list.rb @@ -0,0 +1,25 @@ +module Gitlab + class ChangesList + include Enumerable + + attr_reader :raw_changes + + def initialize(changes) + @raw_changes = changes.kind_of?(String) ? changes.lines : changes + end + + def each(&block) + changes.each(&block) + end + + def changes + @changes ||= begin + @raw_changes.map do |change| + next if change.blank? + oldrev, newrev, ref = change.strip.split(' ') + { oldrev: oldrev, newrev: newrev, ref: ref } + end.compact + end + end + end +end diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 5551fac4b8b..cb1065223d4 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -4,14 +4,14 @@ module Gitlab attr_reader :user_access, :project def initialize(change, user_access:, project:) - @oldrev, @newrev, @ref = change.split(' ') - @branch_name = branch_name(@ref) + @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) + @branch_name = Gitlab::Git.branch_name(@ref) @user_access = user_access @project = project end def exec - error = protected_branch_checks || tag_checks || push_checks + error = push_checks || tag_checks || protected_branch_checks if error GitAccessStatus.new(false, error) @@ -23,6 +23,7 @@ module Gitlab protected def protected_branch_checks + 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) @@ -47,7 +48,7 @@ module Gitlab end def tag_checks - tag_ref = tag_name(@ref) + tag_ref = Gitlab::Git.tag_name(@ref) if tag_ref && protected_tag?(tag_ref) && user_access.cannot_do_action?(:admin_project) "You are not allowed to change existing tags on this project." @@ -73,24 +74,6 @@ module Gitlab def matching_merge_request? Checks::MatchingMergeRequest.new(@newrev, @branch_name, @project).match? end - - def branch_name(ref) - ref = @ref.to_s - if Gitlab::Git.branch_ref?(ref) - Gitlab::Git.ref_name(ref) - else - nil - end - end - - def tag_name(ref) - ref = @ref.to_s - if Gitlab::Git.tag_ref?(ref) - Gitlab::Git.ref_name(ref) - else - nil - end - end end end end diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index ae82c0db3f1..bbfa6cf7d05 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -14,7 +14,7 @@ module Gitlab @config = Loader.new(config).load! @global = Node::Global.new(@config) - @global.process! + @global.compose! end def valid? diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 2de82d40c9d..6b7ab2fdaf2 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -23,9 +23,9 @@ module Gitlab end end - private + def compose!(deps = nil) + return unless valid? - def compose! self.class.nodes.each do |key, factory| factory .value(@config[key]) @@ -33,6 +33,12 @@ module Gitlab @entries[key] = factory.create! end + + yield if block_given? + + @entries.each_value do |entry| + entry.compose!(deps) + end end class_methods do diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 0c782c422b5..8717eabf81e 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -20,11 +20,14 @@ module Gitlab @validator.validate(:new) end - def process! + def [](key) + @entries[key] || Node::Undefined.new + end + + def compose!(deps = nil) return unless valid? - compose! - descendants.each(&:process!) + yield if block_given? end def leaf? @@ -73,11 +76,6 @@ module Gitlab def self.validator Validator end - - private - - def compose! - end end end end diff --git a/lib/gitlab/ci/config/node/environment.rb b/lib/gitlab/ci/config/node/environment.rb new file mode 100644 index 00000000000..d388ab6b879 --- /dev/null +++ b/lib/gitlab/ci/config/node/environment.rb @@ -0,0 +1,68 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents an environment. + # + class Environment < Entry + include Validatable + + ALLOWED_KEYS = %i[name url] + + validations do + validate do + unless hash? || string? + errors.add(:config, 'should be a hash or a string') + end + end + + validates :name, presence: true + validates :name, + type: { + with: String, + message: Gitlab::Regex.environment_name_regex_message } + + validates :name, + format: { + with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } + + with_options if: :hash? do + validates :config, allowed_keys: ALLOWED_KEYS + + validates :url, + length: { maximum: 255 }, + addressable_url: true, + allow_nil: true + end + end + + def hash? + @config.is_a?(Hash) + end + + def string? + @config.is_a?(String) + end + + def name + value[:name] + end + + def url + value[:url] + end + + def value + case @config + when String then { name: @config } + when Hash then @config + else {} + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/factory.rb b/lib/gitlab/ci/config/node/factory.rb index 707b052e6a8..5387f29ad59 100644 --- a/lib/gitlab/ci/config/node/factory.rb +++ b/lib/gitlab/ci/config/node/factory.rb @@ -37,8 +37,8 @@ module Gitlab # See issue #18775. # if @value.nil? - Node::Undefined.new( - fabricate_undefined + Node::Unspecified.new( + fabricate_unspecified ) else fabricate(@node, @value) @@ -47,13 +47,13 @@ module Gitlab private - def fabricate_undefined + def fabricate_unspecified ## # If node has a default value we fabricate concrete node # with default value. # if @node.default.nil? - fabricate(Node::Null) + fabricate(Node::Undefined) else fabricate(@node, @node.default) end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index ccd539fb003..2a2943c9288 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -36,15 +36,15 @@ module Gitlab helpers :before_script, :image, :services, :after_script, :variables, :stages, :types, :cache, :jobs - private - - def compose! - super - - compose_jobs! - compose_deprecated_entries! + def compose!(_deps = nil) + super(self) do + compose_jobs! + compose_deprecated_entries! + end end + private + def compose_jobs! factory = Node::Factory.new(Node::Jobs) .value(@config.except(*self.class.nodes.keys)) diff --git a/lib/gitlab/ci/config/node/hidden_job.rb b/lib/gitlab/ci/config/node/hidden.rb index 073044b66f8..fe4ee8a7fc6 100644 --- a/lib/gitlab/ci/config/node/hidden_job.rb +++ b/lib/gitlab/ci/config/node/hidden.rb @@ -5,11 +5,10 @@ module Gitlab ## # Entry that represents a hidden CI/CD job. # - class HiddenJob < Entry + class Hidden < Entry include Validatable validations do - validates :config, type: Hash validates :config, presence: true end diff --git a/lib/gitlab/ci/config/node/job.rb b/lib/gitlab/ci/config/node/job.rb index e84737acbb9..603334d6793 100644 --- a/lib/gitlab/ci/config/node/job.rb +++ b/lib/gitlab/ci/config/node/job.rb @@ -13,7 +13,7 @@ module Gitlab type stage when artifacts cache dependencies before_script after_script variables environment] - attributes :tags, :allow_failure, :when, :environment, :dependencies + attributes :tags, :allow_failure, :when, :dependencies validations do validates :config, allowed_keys: ALLOWED_KEYS @@ -29,58 +29,65 @@ module Gitlab inclusion: { in: %w[on_success on_failure always manual], message: 'should be on_success, on_failure, ' \ 'always or manual' } - validates :environment, - type: { - with: String, - message: Gitlab::Regex.environment_name_regex_message } - validates :environment, - format: { - with: Gitlab::Regex.environment_name_regex, - message: Gitlab::Regex.environment_name_regex_message } validates :dependencies, array_of_strings: true end end - node :before_script, Script, + node :before_script, Node::Script, description: 'Global before script overridden in this job.' - node :script, Commands, + node :script, Node::Commands, description: 'Commands that will be executed in this job.' - node :stage, Stage, + node :stage, Node::Stage, description: 'Pipeline stage this job will be executed into.' - node :type, Stage, + node :type, Node::Stage, description: 'Deprecated: stage this job will be executed into.' - node :after_script, Script, + node :after_script, Node::Script, description: 'Commands that will be executed when finishing job.' - node :cache, Cache, + node :cache, Node::Cache, description: 'Cache definition for this job.' - node :image, Image, + node :image, Node::Image, description: 'Image that will be used to execute this job.' - node :services, Services, + node :services, Node::Services, description: 'Services that will be used to execute this job.' - node :only, Trigger, + node :only, Node::Trigger, description: 'Refs policy this job will be executed for.' - node :except, Trigger, + node :except, Node::Trigger, description: 'Refs policy this job will be executed for.' - node :variables, Variables, + node :variables, Node::Variables, description: 'Environment variables available for this job.' - node :artifacts, Artifacts, + node :artifacts, Node::Artifacts, description: 'Artifacts configuration for this job.' + node :environment, Node::Environment, + description: 'Environment configuration for this job.' + helpers :before_script, :script, :stage, :type, :after_script, :cache, :image, :services, :only, :except, :variables, - :artifacts + :artifacts, :commands, :environment + + def compose!(deps = nil) + super do + if type_defined? && !stage_defined? + @entries[:stage] = @entries[:type] + end + + @entries.delete(:type) + end + + inherit!(deps) + end def name @metadata[:name] @@ -90,12 +97,30 @@ module Gitlab @config.merge(to_hash.compact) end + def commands + (before_script_value.to_a + script_value.to_a).join("\n") + end + private + def inherit!(deps) + return unless deps + + self.class.nodes.each_key do |key| + global_entry = deps[key] + job_entry = @entries[key] + + if global_entry.specified? && !job_entry.specified? + @entries[key] = global_entry + end + end + end + def to_hash { name: name, before_script: before_script, script: script, + commands: commands, image: image, services: services, stage: stage, @@ -103,19 +128,11 @@ module Gitlab only: only, except: except, variables: variables_defined? ? variables : nil, + environment: environment_defined? ? environment : nil, + environment_name: environment_defined? ? environment[:name] : nil, artifacts: artifacts, after_script: after_script } end - - def compose! - super - - if type_defined? && !stage_defined? - @entries[:stage] = @entries[:type] - end - - @entries.delete(:type) - end end end end diff --git a/lib/gitlab/ci/config/node/jobs.rb b/lib/gitlab/ci/config/node/jobs.rb index 51683c82ceb..d10e80d1a7d 100644 --- a/lib/gitlab/ci/config/node/jobs.rb +++ b/lib/gitlab/ci/config/node/jobs.rb @@ -26,19 +26,23 @@ module Gitlab name.to_s.start_with?('.') end - private - - def compose! - @config.each do |name, config| - node = hidden?(name) ? Node::HiddenJob : Node::Job - - factory = Node::Factory.new(node) - .value(config || {}) - .metadata(name: name) - .with(key: name, parent: self, - description: "#{name} job definition.") + def compose!(deps = nil) + super do + @config.each do |name, config| + node = hidden?(name) ? Node::Hidden : Node::Job + + factory = Node::Factory.new(node) + .value(config || {}) + .metadata(name: name) + .with(key: name, parent: self, + description: "#{name} job definition.") + + @entries[name] = factory.create! + end - @entries[name] = factory.create! + @entries.each_value do |entry| + entry.compose!(deps) + end end end end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb deleted file mode 100644 index 88a5f53f13c..00000000000 --- a/lib/gitlab/ci/config/node/null.rb +++ /dev/null @@ -1,34 +0,0 @@ -module Gitlab - module Ci - class Config - module Node - ## - # This class represents an undefined node. - # - # Implements the Null Object pattern. - # - class Null < Entry - def value - nil - end - - def valid? - true - end - - def errors - [] - end - - def specified? - false - end - - def relevant? - false - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/node/undefined.rb b/lib/gitlab/ci/config/node/undefined.rb index 45fef8c3ae5..33e78023539 100644 --- a/lib/gitlab/ci/config/node/undefined.rb +++ b/lib/gitlab/ci/config/node/undefined.rb @@ -3,15 +3,34 @@ module Gitlab class Config module Node ## - # This class represents an unspecified entry node. + # This class represents an undefined node. # - # It decorates original entry adding method that indicates it is - # unspecified. + # Implements the Null Object pattern. # - class Undefined < SimpleDelegator + class Undefined < Entry + def initialize(*) + super(nil) + end + + def value + nil + end + + def valid? + true + end + + def errors + [] + end + def specified? false end + + def relevant? + false + end end end end diff --git a/lib/gitlab/ci/config/node/unspecified.rb b/lib/gitlab/ci/config/node/unspecified.rb new file mode 100644 index 00000000000..a7d1f6131b8 --- /dev/null +++ b/lib/gitlab/ci/config/node/unspecified.rb @@ -0,0 +1,19 @@ +module Gitlab + module Ci + class Config + module Node + ## + # This class represents an unspecified entry node. + # + # It decorates original entry adding method that indicates it is + # unspecified. + # + class Unspecified < SimpleDelegator + def specified? + false + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/validatable.rb b/lib/gitlab/ci/config/node/validatable.rb index f6e2896dfb2..085e6e988d1 100644 --- a/lib/gitlab/ci/config/node/validatable.rb +++ b/lib/gitlab/ci/config/node/validatable.rb @@ -7,13 +7,11 @@ module Gitlab class_methods do def validator - validator = Class.new(Node::Validator) - - if defined?(@validations) - @validations.each { |rules| validator.class_eval(&rules) } + @validator ||= Class.new(Node::Validator).tap do |validator| + if defined?(@validations) + @validations.each { |rules| validator.class_eval(&rules) } + end end - - validator end private diff --git a/lib/gitlab/ci/pipeline_duration.rb b/lib/gitlab/ci/pipeline_duration.rb new file mode 100644 index 00000000000..a210e76acaa --- /dev/null +++ b/lib/gitlab/ci/pipeline_duration.rb @@ -0,0 +1,141 @@ +module Gitlab + module Ci + # # Introduction - total running time + # + # The problem this module is trying to solve is finding the total running + # time amongst all the jobs, excluding retries and pending (queue) time. + # We could reduce this problem down to finding the union of periods. + # + # So each job would be represented as a `Period`, which consists of + # `Period#first` as when the job started and `Period#last` as when the + # job was finished. A simple example here would be: + # + # * A (1, 3) + # * B (2, 4) + # * C (6, 7) + # + # Here A begins from 1, and ends to 3. B begins from 2, and ends to 4. + # C begins from 6, and ends to 7. Visually it could be viewed as: + # + # 0 1 2 3 4 5 6 7 + # AAAAAAA + # BBBBBBB + # CCCC + # + # The union of A, B, and C would be (1, 4) and (6, 7), therefore the + # total running time should be: + # + # (4 - 1) + (7 - 6) => 4 + # + # # The Algorithm + # + # The algorithm used here for union would be described as follow. + # First we make sure that all periods are sorted by `Period#first`. + # Then we try to merge periods by iterating through the first period + # to the last period. The goal would be merging all overlapped periods + # so that in the end all the periods are discrete. When all periods + # are discrete, we're free to just sum all the periods to get real + # running time. + # + # Here we begin from A, and compare it to B. We could find that + # before A ends, B already started. That is `B.first <= A.last` + # that is `2 <= 3` which means A and B are overlapping! + # + # When we found that two periods are overlapping, we would need to merge + # them into a new period and disregard the old periods. To make a new + # period, we take `A.first` as the new first because remember? we sorted + # them, so `A.first` must be smaller or equal to `B.first`. And we take + # `[A.last, B.last].max` as the new last because we want whoever ended + # later. This could be broken into two cases: + # + # 0 1 2 3 4 + # AAAAAAA + # BBBBBBB + # + # Or: + # + # 0 1 2 3 4 + # AAAAAAAAAA + # BBBB + # + # So that we need to take whoever ends later. Back to our example, + # after merging and discard A and B it could be visually viewed as: + # + # 0 1 2 3 4 5 6 7 + # DDDDDDDDDD + # CCCC + # + # Now we could go on and compare the newly created D and the old C. + # We could figure out that D and C are not overlapping by checking + # `C.first <= D.last` is `false`. Therefore we need to keep both C + # and D. The example would end here because there are no more jobs. + # + # After having the union of all periods, we just need to sum the length + # of all periods to get total time. + # + # (4 - 1) + (7 - 6) => 4 + # + # That is 4 is the answer in the example. + module PipelineDuration + extend self + + Period = Struct.new(:first, :last) do + def duration + last - first + end + end + + def from_pipeline(pipeline) + status = %w[success failed running canceled] + builds = pipeline.builds.latest. + where(status: status).where.not(started_at: nil).order(:started_at) + + from_builds(builds) + end + + def from_builds(builds) + now = Time.now + + periods = builds.map do |b| + Period.new(b.started_at, b.finished_at || now) + end + + from_periods(periods) + end + + # periods should be sorted by `first` + def from_periods(periods) + process_duration(process_periods(periods)) + end + + private + + def process_periods(periods) + return periods if periods.empty? + + periods.drop(1).inject([periods.first]) do |result, current| + previous = result.last + + if overlap?(previous, current) + result[-1] = merge(previous, current) + result + else + result << current + end + end + end + + def overlap?(previous, current) + current.first <= previous.last + end + + def merge(previous, current) + Period.new(previous.first, [previous.last, current.last].max) + end + + def process_duration(periods) + periods.sum(&:duration) + end + end + end +end diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index 9bef9037ad6..58f86abc5c4 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -22,7 +22,9 @@ module Gitlab @extractor.analyze(closing_statements.join(" ")) - @extractor.issues + @extractor.issues.reject do |issue| + @extractor.project.forked_from?(issue.project) # Don't extract issues on original project + end end end end diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb new file mode 100644 index 00000000000..dff9e29c6a5 --- /dev/null +++ b/lib/gitlab/conflict/file.rb @@ -0,0 +1,197 @@ +module Gitlab + module Conflict + class File + include Gitlab::Routing.url_helpers + include IconsHelper + + class MissingResolution < StandardError + end + + CONTEXT_LINES = 3 + + attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository + + def initialize(merge_file_result, conflict, merge_request:) + @merge_file_result = merge_file_result + @their_path = conflict[:theirs][:path] + @our_path = conflict[:ours][:path] + @our_mode = conflict[:ours][:mode] + @merge_request = merge_request + @repository = merge_request.project.repository + @match_line_headers = {} + end + + # Array of Gitlab::Diff::Line objects + def lines + @lines ||= Gitlab::Conflict::Parser.new.parse(merge_file_result[:data], + our_path: our_path, + their_path: their_path, + parent_file: self) + end + + def resolve_lines(resolution) + section_id = nil + + lines.map do |line| + unless line.type + section_id = nil + next line + end + + section_id ||= line_code(line) + + case resolution[section_id] + when 'head' + next unless line.type == 'new' + when 'origin' + next unless line.type == 'old' + else + raise MissingResolution, "Missing resolution for section ID: #{section_id}" + end + + line + end.compact + end + + def highlight_lines! + their_file = lines.reject { |line| line.type == 'new' }.map(&:text).join("\n") + our_file = lines.reject { |line| line.type == 'old' }.map(&:text).join("\n") + + their_highlight = Gitlab::Highlight.highlight(their_path, their_file, repository: repository).lines + our_highlight = Gitlab::Highlight.highlight(our_path, our_file, repository: repository).lines + + lines.each do |line| + if line.type == 'old' + line.rich_text = their_highlight[line.old_line - 1].try(:html_safe) + else + line.rich_text = our_highlight[line.new_line - 1].try(:html_safe) + end + end + end + + def sections + return @sections if @sections + + chunked_lines = lines.chunk { |line| line.type.nil? }.to_a + match_line = nil + + sections_count = chunked_lines.size + + @sections = chunked_lines.flat_map.with_index do |(no_conflict, lines), i| + section = nil + + # We need to reduce context sections to CONTEXT_LINES. Conflict sections are + # always shown in full. + if no_conflict + conflict_before = i > 0 + conflict_after = (sections_count - i) > 1 + + if conflict_before && conflict_after + # Create a gap in a long context section. + if lines.length > CONTEXT_LINES * 2 + head_lines = lines.first(CONTEXT_LINES) + tail_lines = lines.last(CONTEXT_LINES) + + # Ensure any existing match line has text for all lines up to the last + # line of its context. + update_match_line_text(match_line, head_lines.last) + + # Insert a new match line after the created gap. + match_line = create_match_line(tail_lines.first) + + section = [ + { conflict: false, lines: head_lines }, + { conflict: false, lines: tail_lines.unshift(match_line) } + ] + end + elsif conflict_after + tail_lines = lines.last(CONTEXT_LINES) + + # Create a gap and insert a match line at the start. + if lines.length > tail_lines.length + match_line = create_match_line(tail_lines.first) + + tail_lines.unshift(match_line) + end + + lines = tail_lines + elsif conflict_before + # We're at the end of the file (no conflicts after), so just remove extra + # trailing lines. + lines = lines.first(CONTEXT_LINES) + end + end + + # We want to update the match line's text every time unless we've already + # created a gap and its corresponding match line. + update_match_line_text(match_line, lines.last) unless section + + section ||= { conflict: !no_conflict, lines: lines } + section[:id] = line_code(lines.first) unless no_conflict + section + end + end + + def line_code(line) + Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos) + end + + def create_match_line(line) + Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos) + end + + # Any line beginning with a letter, an underscore, or a dollar can be used in a + # match line header. Only context sections can contain match lines, as match lines + # have to exist in both versions of the file. + def find_match_line_header(index) + return @match_line_headers[index] if @match_line_headers.key?(index) + + @match_line_headers[index] = begin + if index >= 0 + line = lines[index] + + if line.type.nil? && line.text.match(/\A[A-Za-z$_]/) + " #{line.text}" + else + find_match_line_header(index - 1) + end + end + end + end + + # Set the match line's text for the current line. A match line takes its start + # position and context header (where present) from itself, and its end position from + # the line passed in. + def update_match_line_text(match_line, line) + return unless match_line + + header = find_match_line_header(match_line.index - 1) + + match_line.text = "@@ -#{match_line.old_pos},#{line.old_pos} +#{match_line.new_pos},#{line.new_pos} @@#{header}" + end + + def as_json(opts = nil) + { + old_path: their_path, + new_path: our_path, + blob_icon: file_type_icon_class('file', our_mode, our_path), + blob_path: namespace_project_blob_path(merge_request.project.namespace, + merge_request.project, + ::File.join(merge_request.diff_refs.head_sha, our_path)), + sections: sections + } + end + + # Don't try to print merge_request or repository. + def inspect + instance_variables = [:merge_file_result, :their_path, :our_path, :our_mode].map do |instance_variable| + value = instance_variable_get("@#{instance_variable}") + + "#{instance_variable}=\"#{value}\"" + end + + "#<#{self.class} #{instance_variables.join(' ')}>" + end + end + end +end diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb new file mode 100644 index 00000000000..bbd0427a2c8 --- /dev/null +++ b/lib/gitlab/conflict/file_collection.rb @@ -0,0 +1,57 @@ +module Gitlab + module Conflict + class FileCollection + class ConflictSideMissing < StandardError + end + + attr_reader :merge_request, :our_commit, :their_commit + + def initialize(merge_request) + @merge_request = merge_request + @our_commit = merge_request.source_branch_head.raw.raw_commit + @their_commit = merge_request.target_branch_head.raw.raw_commit + end + + def repository + merge_request.project.repository + end + + def merge_index + @merge_index ||= repository.rugged.merge_commits(our_commit, their_commit) + end + + def files + @files ||= merge_index.conflicts.map do |conflict| + raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours] + + Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]), + conflict, + merge_request: merge_request) + end + end + + def as_json(opts = nil) + { + target_branch: merge_request.target_branch, + source_branch: merge_request.source_branch, + commit_sha: merge_request.diff_head_sha, + commit_message: default_commit_message, + files: files + } + end + + def default_commit_message + conflict_filenames = merge_index.conflicts.map do |conflict| + "# #{conflict[:ours][:path]}" + end + + <<EOM.chomp +Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branch}' + +# Conflicts: +#{conflict_filenames.join("\n")} +EOM + end + end + end +end diff --git a/lib/gitlab/conflict/parser.rb b/lib/gitlab/conflict/parser.rb new file mode 100644 index 00000000000..98e842cded3 --- /dev/null +++ b/lib/gitlab/conflict/parser.rb @@ -0,0 +1,71 @@ +module Gitlab + module Conflict + class Parser + class ParserError < StandardError + end + + class UnexpectedDelimiter < ParserError + end + + class MissingEndDelimiter < ParserError + end + + class UnmergeableFile < ParserError + end + + class UnsupportedEncoding < ParserError + end + + def parse(text, our_path:, their_path:, parent_file: nil) + raise UnmergeableFile if text.blank? # Typically a binary file + raise UnmergeableFile if text.length > 200.kilobytes + + begin + text.to_json + rescue Encoding::UndefinedConversionError + raise UnsupportedEncoding + end + + line_obj_index = 0 + line_old = 1 + line_new = 1 + type = nil + lines = [] + conflict_start = "<<<<<<< #{our_path}" + conflict_middle = '=======' + conflict_end = ">>>>>>> #{their_path}" + + text.each_line.map do |line| + full_line = line.delete("\n") + + if full_line == conflict_start + raise UnexpectedDelimiter unless type.nil? + + type = 'new' + elsif full_line == conflict_middle + raise UnexpectedDelimiter unless type == 'new' + + type = 'old' + elsif full_line == conflict_end + raise UnexpectedDelimiter unless type == 'old' + + type = nil + elsif line[0] == '\\' + type = 'nonewline' + lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file) + else + lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file) + line_old += 1 if type != 'new' + line_new += 1 if type != 'old' + + line_obj_index += 1 + end + end + + raise MissingEndDelimiter unless type.nil? + + lines + end + end + end +end diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb index 9dc2602867e..b164f5a2eea 100644 --- a/lib/gitlab/contributions_calendar.rb +++ b/lib/gitlab/contributions_calendar.rb @@ -1,16 +1,16 @@ module Gitlab class ContributionsCalendar - attr_reader :timestamps, :projects, :user + attr_reader :activity_dates, :projects, :user def initialize(projects, user) @projects = projects @user = user end - def timestamps - return @timestamps if @timestamps.present? + def activity_dates + return @activity_dates if @activity_dates.present? - @timestamps = {} + @activity_dates = {} date_from = 1.year.ago events = Event.reorder(nil).contributions.where(author_id: user.id). @@ -19,19 +19,17 @@ module Gitlab select('date(created_at) as date, count(id) as total_amount'). map(&:attributes) - dates = (1.year.ago.to_date..Date.today).to_a + activity_dates = (1.year.ago.to_date..Date.today).to_a - dates.each do |date| - date_id = date.to_time.to_i.to_s - @timestamps[date_id] = 0 + activity_dates.each do |date| day_events = events.find { |day_events| day_events["date"] == date } if day_events - @timestamps[date_id] = day_events["total_amount"] + @activity_dates[date] = day_events["total_amount"] end end - @timestamps + @activity_dates end def events_by_date(date) diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index 735331df66c..ef9160d6437 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -30,6 +30,7 @@ module Gitlab signup_enabled: Settings.gitlab['signup_enabled'], signin_enabled: Settings.gitlab['signin_enabled'], gravatar_enabled: Settings.gravatar['enabled'], + koding_enabled: false, sign_in_text: nil, after_sign_up_text: nil, help_page_text: nil, @@ -40,7 +41,7 @@ module Gitlab default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], domain_whitelist: Settings.gitlab['domain_whitelist'], - import_sources: %w[github bitbucket gitlab gitorious google_code fogbugz git gitlab_project], + import_sources: %w[github bitbucket gitlab google_code fogbugz git gitlab_project], shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], max_artifacts_size: Settings.artifacts['max_size'], require_two_factor_authentication: false, @@ -58,10 +59,8 @@ module Gitlab # When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised active_db_connection = ActiveRecord::Base.connection.active? rescue false - ENV['USE_DB'] != 'false' && active_db_connection && - ActiveRecord::Base.connection.table_exists?('application_settings') - + ActiveRecord::Base.connection.table_exists?('application_settings') rescue ActiveRecord::NoDatabaseError false end diff --git a/lib/gitlab/build_data_builder.rb b/lib/gitlab/data_builder/build.rb index 9f45aefda0f..6548e6475c6 100644 --- a/lib/gitlab/build_data_builder.rb +++ b/lib/gitlab/data_builder/build.rb @@ -1,6 +1,8 @@ module Gitlab - class BuildDataBuilder - class << self + module DataBuilder + module Build + extend self + def build(build) project = build.project commit = build.pipeline diff --git a/lib/gitlab/note_data_builder.rb b/lib/gitlab/data_builder/note.rb index 8bdc89a7751..50fea1232af 100644 --- a/lib/gitlab/note_data_builder.rb +++ b/lib/gitlab/data_builder/note.rb @@ -1,6 +1,8 @@ module Gitlab - class NoteDataBuilder - class << self + module DataBuilder + module Note + extend self + # Produce a hash of post-receive data # # For all notes: diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb new file mode 100644 index 00000000000..06a783ebc1c --- /dev/null +++ b/lib/gitlab/data_builder/pipeline.rb @@ -0,0 +1,62 @@ +module Gitlab + module DataBuilder + module Pipeline + extend self + + def build(pipeline) + { + object_kind: 'pipeline', + object_attributes: hook_attrs(pipeline), + user: pipeline.user.try(:hook_attrs), + project: pipeline.project.hook_attrs(backward: false), + commit: pipeline.commit.try(:hook_attrs), + builds: pipeline.builds.map(&method(:build_hook_attrs)) + } + end + + def hook_attrs(pipeline) + { + id: pipeline.id, + ref: pipeline.ref, + tag: pipeline.tag, + sha: pipeline.sha, + before_sha: pipeline.before_sha, + status: pipeline.status, + stages: pipeline.stages, + created_at: pipeline.created_at, + finished_at: pipeline.finished_at, + duration: pipeline.duration + } + end + + def build_hook_attrs(build) + { + id: build.id, + stage: build.stage, + name: build.name, + status: build.status, + created_at: build.created_at, + started_at: build.started_at, + finished_at: build.finished_at, + when: build.when, + manual: build.manual?, + user: build.user.try(:hook_attrs), + runner: build.runner && runner_hook_attrs(build.runner), + artifacts_file: { + filename: build.artifacts_file.filename, + size: build.artifacts_size + } + } + end + + def runner_hook_attrs(runner) + { + id: runner.id, + description: runner.description, + active: runner.active?, + is_shared: runner.is_shared? + } + end + end + end +end diff --git a/lib/gitlab/push_data_builder.rb b/lib/gitlab/data_builder/push.rb index c8f12577112..4f81863da35 100644 --- a/lib/gitlab/push_data_builder.rb +++ b/lib/gitlab/data_builder/push.rb @@ -1,6 +1,8 @@ module Gitlab - class PushDataBuilder - class << self + module DataBuilder + module Push + extend self + # Produce a hash of post-receive data # # data = { diff --git a/lib/gitlab/database/date_time.rb b/lib/gitlab/database/date_time.rb new file mode 100644 index 00000000000..b6a89f715fd --- /dev/null +++ b/lib/gitlab/database/date_time.rb @@ -0,0 +1,27 @@ +module Gitlab + module Database + module DateTime + # Find the first of the `end_time_attrs` that isn't `NULL`. Subtract from it + # the first of the `start_time_attrs` that isn't NULL. `SELECT` the resulting interval + # along with an alias specified by the `as` parameter. + # + # Note: For MySQL, the interval is returned in seconds. + # For PostgreSQL, the interval is returned as an INTERVAL type. + def subtract_datetimes(query_so_far, end_time_attrs, start_time_attrs, as) + diff_fn = if Gitlab::Database.postgresql? + Arel::Nodes::Subtraction.new( + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs))) + elsif Gitlab::Database.mysql? + Arel::Nodes::NamedFunction.new( + "TIMESTAMPDIFF", + [Arel.sql('second'), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))]) + end + + query_so_far.project(diff_fn.as(as)) + end + end + end +end diff --git a/lib/gitlab/database/median.rb b/lib/gitlab/database/median.rb new file mode 100644 index 00000000000..1444d25ebc7 --- /dev/null +++ b/lib/gitlab/database/median.rb @@ -0,0 +1,112 @@ +# https://www.periscopedata.com/blog/medians-in-sql.html +module Gitlab + module Database + module Median + def median_datetime(arel_table, query_so_far, column_sym) + median_queries = + if Gitlab::Database.postgresql? + pg_median_datetime_sql(arel_table, query_so_far, column_sym) + elsif Gitlab::Database.mysql? + mysql_median_datetime_sql(arel_table, query_so_far, column_sym) + end + + results = Array.wrap(median_queries).map do |query| + ActiveRecord::Base.connection.execute(query) + end + extract_median(results).presence + end + + def extract_median(results) + result = results.compact.first + + if Gitlab::Database.postgresql? + result = result.first.presence + median = result['median'] if result + median.to_f if median + elsif Gitlab::Database.mysql? + result.to_a.flatten.first + end + end + + def mysql_median_datetime_sql(arel_table, query_so_far, column_sym) + query = arel_table. + from(arel_table.project(Arel.sql('*')).order(arel_table[column_sym]).as(arel_table.table_name)). + project(average([arel_table[column_sym]], 'median')). + where( + Arel::Nodes::Between.new( + Arel.sql("(select @row_id := @row_id + 1)"), + Arel::Nodes::And.new( + [Arel.sql('@ct/2.0'), + Arel.sql('@ct/2.0 + 1')] + ) + ) + ). + # Disallow negative values + where(arel_table[column_sym].gteq(0)) + + [ + Arel.sql("CREATE TEMPORARY TABLE IF NOT EXISTS #{query_so_far.to_sql}"), + Arel.sql("set @ct := (select count(1) from #{arel_table.table_name});"), + Arel.sql("set @row_id := 0;"), + query.to_sql, + Arel.sql("DROP TEMPORARY TABLE IF EXISTS #{arel_table.table_name};") + ] + end + + def pg_median_datetime_sql(arel_table, query_so_far, column_sym) + # Create a CTE with the column we're operating on, row number (after sorting by the column + # we're operating on), and count of the table we're operating on (duplicated across) all rows + # of the CTE. For example, if we're looking to find the median of the `projects.star_count` + # column, the CTE might look like this: + # + # star_count | row_id | ct + # ------------+--------+---- + # 5 | 1 | 3 + # 9 | 2 | 3 + # 15 | 3 | 3 + cte_table = Arel::Table.new("ordered_records") + cte = Arel::Nodes::As.new( + cte_table, + arel_table. + project( + arel_table[column_sym].as(column_sym.to_s), + Arel::Nodes::Over.new(Arel::Nodes::NamedFunction.new("row_number", []), + Arel::Nodes::Window.new.order(arel_table[column_sym])).as('row_id'), + arel_table.project("COUNT(1)").as('ct')). + # Disallow negative values + where(arel_table[column_sym].gteq(zero_interval))) + + # From the CTE, select either the middle row or the middle two rows (this is accomplished + # by 'where cte.row_id between cte.ct / 2.0 AND cte.ct / 2.0 + 1'). Find the average of the + # selected rows, and this is the median value. + cte_table.project(average([extract_epoch(cte_table[column_sym])], "median")). + where( + Arel::Nodes::Between.new( + cte_table[:row_id], + Arel::Nodes::And.new( + [(cte_table[:ct] / Arel.sql('2.0')), + (cte_table[:ct] / Arel.sql('2.0') + 1)] + ) + ) + ). + with(query_so_far, cte). + to_sql + end + + private + + def average(args, as) + Arel::Nodes::NamedFunction.new("AVG", args, as) + end + + def extract_epoch(arel_attribute) + Arel.sql(%Q{EXTRACT(EPOCH FROM "#{arel_attribute.relation.name}"."#{arel_attribute.name}")}) + end + + # Need to cast '0' to an INTERVAL before we can check if the interval is positive + def zero_interval + Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("'0' AS INTERVAL")]) + end + end + end +end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 927f9dad20b..0bd6e148ba8 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -129,12 +129,14 @@ module Gitlab # column - The name of the column to add. # type - The column type (e.g. `:integer`). # default - The default value for the column. + # limit - Sets a column limit. For example, for :integer, the default is + # 4-bytes. Set `limit: 8` to allow 8-byte integers. # allow_null - When set to `true` the column will allow NULL values, the # default is to not allow NULL values. # # This method can also take a block which is passed directly to the # `update_column_in_batches` method. - def add_column_with_default(table, column, type, default:, allow_null: false, &block) + def add_column_with_default(table, column, type, default:, limit: nil, allow_null: false, &block) if transaction_open? raise 'add_column_with_default can not be run inside a transaction, ' \ 'you can disable transactions by calling disable_ddl_transaction! ' \ @@ -144,7 +146,11 @@ module Gitlab disable_statement_timeout transaction do - add_column(table, column, type, default: nil) + if limit + add_column(table, column, type, default: nil, limit: limit) + else + add_column(table, column, type, default: nil) + end # Changing the default before the update ensures any newly inserted # rows already use the proper default value. diff --git a/lib/gitlab/diff/file_collection/merge_request.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb index 4f946908e2f..36348b33943 100644 --- a/lib/gitlab/diff/file_collection/merge_request.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb @@ -1,14 +1,14 @@ module Gitlab module Diff module FileCollection - class MergeRequest < Base - def initialize(merge_request, diff_options:) - @merge_request = merge_request + class MergeRequestDiff < Base + def initialize(merge_request_diff, diff_options:) + @merge_request_diff = merge_request_diff - super(merge_request, - project: merge_request.project, + super(merge_request_diff, + project: merge_request_diff.project, diff_options: diff_options, - diff_refs: merge_request.diff_refs) + diff_refs: merge_request_diff.diff_refs) end def diff_files @@ -61,11 +61,11 @@ module Gitlab end def cacheable? - @merge_request.merge_request_diff.present? + @merge_request_diff.present? end def cache_key - [@merge_request.merge_request_diff, 'highlighted-diff-files', diff_options] + [@merge_request_diff, 'highlighted-diff-files', diff_options] end end end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index cf097e0d0de..80a146b4a5a 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -2,11 +2,13 @@ module Gitlab module Diff class Line attr_reader :type, :index, :old_pos, :new_pos + attr_writer :rich_text attr_accessor :text - def initialize(text, type, index, old_pos, new_pos) + def initialize(text, type, index, old_pos, new_pos, parent_file: nil) @text, @type, @index = text, type, index @old_pos, @new_pos = old_pos, new_pos + @parent_file = parent_file end def self.init_from_hash(hash) @@ -43,9 +45,25 @@ module Gitlab type == 'old' end + def rich_text + @parent_file.highlight_lines! if @parent_file && !@rich_text + + @rich_text + end + def meta? type == 'match' || type == 'nonewline' end + + def as_json(opts = nil) + { + type: type, + old_line: old_line, + new_line: new_line, + text: text, + rich_text: rich_text || text + } + end end end end diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb index 2fdcf8d7838..ecf62dead35 100644 --- a/lib/gitlab/diff/position.rb +++ b/lib/gitlab/diff/position.rb @@ -139,13 +139,19 @@ module Gitlab private def find_diff_file(repository) - diffs = Gitlab::Git::Compare.new( - repository.raw_repository, - start_sha, - head_sha - ).diffs(paths: paths) + # We're at the initial commit, so just get that as we can't compare to anything. + if Gitlab::Git.blank_ref?(start_sha) + compare = Gitlab::Git::Commit.find(repository.raw_repository, head_sha) + else + compare = Gitlab::Git::Compare.new( + repository.raw_repository, + start_sha, + head_sha + ) + end + + diff = compare.diffs(paths: paths).first - diff = diffs.first return unless diff Gitlab::Diff::File.new(diff, repository: repository, diff_refs: diff_refs) diff --git a/lib/gitlab/downtime_check/message.rb b/lib/gitlab/downtime_check/message.rb index 4446e921e0d..40a4815a9a0 100644 --- a/lib/gitlab/downtime_check/message.rb +++ b/lib/gitlab/downtime_check/message.rb @@ -1,10 +1,10 @@ module Gitlab class DowntimeCheck class Message - attr_reader :path, :offline, :reason + attr_reader :path, :offline - OFFLINE = "\e[32moffline\e[0m" - ONLINE = "\e[31monline\e[0m" + OFFLINE = "\e[31moffline\e[0m" + ONLINE = "\e[32monline\e[0m" # path - The file path of the migration. # offline - When set to `true` the migration will require downtime. @@ -19,10 +19,21 @@ module Gitlab label = offline ? OFFLINE : ONLINE message = "[#{label}]: #{path}" - message += ": #{reason}" if reason + + if reason? + message += ":\n\n#{reason}\n\n" + end message end + + def reason? + @reason.present? + end + + def reason + @reason.strip.lines.map(&:strip).join("\n") + end end end end diff --git a/lib/gitlab/email/handler.rb b/lib/gitlab/email/handler.rb index bd3267e2a80..5cf9d5ebe28 100644 --- a/lib/gitlab/email/handler.rb +++ b/lib/gitlab/email/handler.rb @@ -4,7 +4,8 @@ require 'gitlab/email/handler/create_issue_handler' module Gitlab module Email module Handler - HANDLERS = [CreateNoteHandler, CreateIssueHandler] + # The `CreateIssueHandler` feature is disabled for the time being. + HANDLERS = [CreateNoteHandler] def self.for(mail, mail_key) HANDLERS.find do |klass| diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb index b7ed11cb638..7cccf465334 100644 --- a/lib/gitlab/email/handler/base_handler.rb +++ b/lib/gitlab/email/handler/base_handler.rb @@ -45,6 +45,7 @@ module Gitlab def verify_record!(record:, invalid_exception:, record_name:) return if record.persisted? + return if record.errors.key?(:commands_only) error_title = "The #{record_name} could not be created for the following reasons:" diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index 9213cfb51e8..a40c44eb1bc 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -1,5 +1,5 @@ -require 'gitlab/email/handler' +require_dependency 'gitlab/email/handler' # Inspired in great part by Discourse's Email::Receiver module Gitlab diff --git a/lib/gitlab/emoji.rb b/lib/gitlab/emoji.rb index b63213ae208..bbbca8acc40 100644 --- a/lib/gitlab/emoji.rb +++ b/lib/gitlab/emoji.rb @@ -10,12 +10,20 @@ module Gitlab Gemojione.index.instance_variable_get(:@emoji_by_moji) end + def emojis_unicodes + emojis_by_moji.keys + end + def emojis_names - emojis.keys.sort + emojis.keys end def emoji_filename(name) emojis[name]["unicode"] end + + def emoji_unicode_filename(moji) + emojis_by_moji[moji]["unicode"] + end end end diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index 191bea86ac3..3cd515e4a3a 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -9,6 +9,34 @@ module Gitlab ref.gsub(/\Arefs\/(tags|heads)\//, '') end + def branch_name(ref) + ref = ref.to_s + if self.branch_ref?(ref) + self.ref_name(ref) + else + nil + end + end + + def committer_hash(email:, name:) + return if email.nil? || name.nil? + + { + email: email, + name: name, + time: Time.now + } + end + + def tag_name(ref) + ref = ref.to_s + if self.tag_ref?(ref) + self.ref_name(ref) + else + nil + end + end + def tag_ref?(ref) ref.start_with?(TAG_REF_PREFIX) end diff --git a/lib/gitlab/git/hook.rb b/lib/gitlab/git/hook.rb index 9b681e636c7..bd90d24a2ec 100644 --- a/lib/gitlab/git/hook.rb +++ b/lib/gitlab/git/hook.rb @@ -17,11 +17,13 @@ module Gitlab def trigger(gl_id, oldrev, newrev, ref) return [true, nil] unless exists? - case name - when "pre-receive", "post-receive" - call_receive_hook(gl_id, oldrev, newrev, ref) - when "update" - call_update_hook(gl_id, oldrev, newrev, ref) + Bundler.with_clean_env do + case name + when "pre-receive", "post-receive" + call_receive_hook(gl_id, oldrev, newrev, ref) + when "update" + call_update_hook(gl_id, oldrev, newrev, ref) + end end end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 8e8f39d9cb2..799794c0171 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -5,16 +5,17 @@ module Gitlab DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive } PUSH_COMMANDS = %w{ git-receive-pack } - attr_reader :actor, :project, :protocol, :user_access + attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities - def initialize(actor, project, protocol) + def initialize(actor, project, protocol, authentication_abilities:) @actor = actor @project = project @protocol = protocol + @authentication_abilities = authentication_abilities @user_access = UserAccess.new(user, project: project) end - def check(cmd, changes = nil) + def check(cmd, changes) return build_status_object(false, "Git access over #{protocol.upcase} is not allowed") unless protocol_allowed? unless actor @@ -60,14 +61,26 @@ module Gitlab end def user_download_access_check - unless user_access.can_do_action?(:download_code) + unless user_can_download_code? || build_can_download_code? return build_status_object(false, "You are not allowed to download code from this project.") end build_status_object(true) end + def user_can_download_code? + authentication_abilities.include?(:download_code) && user_access.can_do_action?(:download_code) + end + + def build_can_download_code? + authentication_abilities.include?(:build_download_code) && user_access.can_do_action?(:build_download_code) + end + def user_push_access_check(changes) + unless authentication_abilities.include?(:push_code) + return build_status_object(false, "You are not allowed to upload code for this project.") + end + if changes.blank? return build_status_object(true) end @@ -76,10 +89,10 @@ module Gitlab return build_status_object(false, "A repository for this project does not exist yet.") end - changes = changes.lines if changes.kind_of?(String) + changes_list = Gitlab::ChangesList.new(changes) # Iterate over all changes to find if user allowed all of them to be applied - changes.map(&:strip).reject(&:blank?).each do |change| + changes_list.each do |change| status = change_access_check(change) unless status.allowed? # If user does not have access to make at least one change - cancel all push @@ -134,7 +147,7 @@ module Gitlab end def build_status_object(status, message = '') - GitAccessStatus.new(status, message) + Gitlab::GitAccessStatus.new(status, message) end end end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index a088e19d1e7..d32bdd86427 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -39,7 +39,6 @@ module Gitlab end def deserialize_changes(changes) - changes = Base64.decode64(changes) unless changes.include?(' ') changes = utf8_encode_changes(changes) changes.lines end diff --git a/lib/gitlab/github_import/base_formatter.rb b/lib/gitlab/github_import/base_formatter.rb index 72992baffd4..8cacf4f4925 100644 --- a/lib/gitlab/github_import/base_formatter.rb +++ b/lib/gitlab/github_import/base_formatter.rb @@ -15,11 +15,16 @@ module Gitlab private - def gl_user_id(github_id) + def gitlab_user_id(github_id) User.joins(:identities). find_by("identities.extern_uid = ? AND identities.provider = 'github'", github_id.to_s). try(:id) end + + def gitlab_author_id + return @gitlab_author_id if defined?(@gitlab_author_id) + @gitlab_author_id = gitlab_user_id(raw_data.user.id) + end end end end diff --git a/lib/gitlab/github_import/branch_formatter.rb b/lib/gitlab/github_import/branch_formatter.rb index 7d2d545b84e..4750675ae9d 100644 --- a/lib/gitlab/github_import/branch_formatter.rb +++ b/lib/gitlab/github_import/branch_formatter.rb @@ -7,10 +7,6 @@ module Gitlab branch_exists? && commit_exists? end - def name - @name ||= exists? ? ref : "#{ref}-#{short_id}" - end - def valid? repo.present? end diff --git a/lib/gitlab/github_import/client.rb b/lib/gitlab/github_import/client.rb index 084e514492c..7f424b74efb 100644 --- a/lib/gitlab/github_import/client.rb +++ b/lib/gitlab/github_import/client.rb @@ -52,7 +52,7 @@ module Gitlab def method_missing(method, *args, &block) if api.respond_to?(method) - request { api.send(method, *args, &block) } + request(method, *args, &block) else super(method, *args, &block) end @@ -99,20 +99,29 @@ module Gitlab rate_limit.resets_in + GITHUB_SAFE_SLEEP_TIME end - def request + def request(method, *args, &block) sleep rate_limit_sleep_time if rate_limit_exceed? - data = yield + data = api.send(method, *args) + return data unless data.is_a?(Array) + if block_given? + yield data + each_response_page(&block) + else + each_response_page { |page| data.concat(page) } + data + end + end + + def each_response_page last_response = api.last_response while last_response.rels[:next] sleep rate_limit_sleep_time if rate_limit_exceed? last_response = last_response.rels[:next].get - data.concat(last_response.data) if last_response.data.is_a?(Array) + yield last_response.data if last_response.data.is_a?(Array) end - - data end end end diff --git a/lib/gitlab/github_import/comment_formatter.rb b/lib/gitlab/github_import/comment_formatter.rb index 2c1b94ef2cd..2bddcde2b7c 100644 --- a/lib/gitlab/github_import/comment_formatter.rb +++ b/lib/gitlab/github_import/comment_formatter.rb @@ -21,7 +21,7 @@ module Gitlab end def author_id - gl_user_id(raw_data.user.id) || project.creator_id + gitlab_author_id || project.creator_id end def body @@ -52,7 +52,11 @@ module Gitlab end def note - formatter.author_line(author) + body + if gitlab_author_id + body + else + formatter.author_line(author) + body + end end def type diff --git a/lib/gitlab/github_import/hook_formatter.rb b/lib/gitlab/github_import/hook_formatter.rb deleted file mode 100644 index db1fabaa18a..00000000000 --- a/lib/gitlab/github_import/hook_formatter.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Gitlab - module GithubImport - class HookFormatter - EVENTS = %w[* create delete pull_request push].freeze - - attr_reader :raw - - delegate :id, :name, :active, to: :raw - - def initialize(raw) - @raw = raw - end - - def config - raw.config.attrs - end - - def valid? - (EVENTS & raw.events).any? && active - end - end - end -end diff --git a/lib/gitlab/github_import/importer.rb b/lib/gitlab/github_import/importer.rb index 3932fcb1eda..4b70f33a851 100644 --- a/lib/gitlab/github_import/importer.rb +++ b/lib/gitlab/github_import/importer.rb @@ -3,24 +3,33 @@ module Gitlab class Importer include Gitlab::ShellAdapter - attr_reader :client, :project, :repo, :repo_url + attr_reader :client, :errors, :project, :repo, :repo_url def initialize(project) @project = project @repo = project.import_source @repo_url = project.import_url + @errors = [] + @labels = {} if credentials @client = Client.new(credentials[:user]) - @formatter = Gitlab::ImportFormatter.new else raise Projects::ImportService::Error, "Unable to find project import data credentials for project ID: #{@project.id}" end end def execute - import_labels && import_milestones && import_issues && - import_pull_requests && import_wiki + import_labels + import_milestones + import_issues + import_pull_requests + import_comments + import_wiki + import_releases + handle_errors + + true end private @@ -29,159 +38,161 @@ module Gitlab @credentials ||= project.import_data.credentials if project.import_data end - def import_labels - labels = client.labels(repo, per_page: 100) - labels.each { |raw| LabelFormatter.new(project, raw).create! } + def handle_errors + return unless errors.any? - true - rescue ActiveRecord::RecordInvalid => e - raise Projects::ImportService::Error, e.message + project.update_column(:import_error, { + message: 'The remote data could not be fully imported.', + errors: errors + }.to_json) end - def import_milestones - milestones = client.milestones(repo, state: :all, per_page: 100) - milestones.each { |raw| MilestoneFormatter.new(project, raw).create! } + def import_labels + client.labels(repo, per_page: 100) do |labels| + labels.each do |raw| + begin + label = LabelFormatter.new(project, raw).create! + @labels[label.title] = label.id + rescue => e + errors << { type: :label, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } + end + end + end + end - true - rescue ActiveRecord::RecordInvalid => e - raise Projects::ImportService::Error, e.message + def import_milestones + client.milestones(repo, state: :all, per_page: 100) do |milestones| + milestones.each do |raw| + begin + MilestoneFormatter.new(project, raw).create! + rescue => e + errors << { type: :milestone, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } + end + end + end end def import_issues - issues = client.issues(repo, state: :all, sort: :created, direction: :asc, per_page: 100) - - issues.each do |raw| - gh_issue = IssueFormatter.new(project, raw) - - if gh_issue.valid? - issue = gh_issue.create! - apply_labels(issue) - import_comments(issue) if gh_issue.has_comments? + client.issues(repo, state: :all, sort: :created, direction: :asc, per_page: 100) do |issues| + issues.each do |raw| + gh_issue = IssueFormatter.new(project, raw) + + if gh_issue.valid? + begin + issue = gh_issue.create! + apply_labels(issue, raw) + rescue => e + errors << { type: :issue, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } + end + end end end - - true - rescue ActiveRecord::RecordInvalid => e - raise Projects::ImportService::Error, e.message end def import_pull_requests - disable_webhooks - - pull_requests = client.pull_requests(repo, state: :all, sort: :created, direction: :asc, per_page: 100) - pull_requests = pull_requests.map { |raw| PullRequestFormatter.new(project, raw) }.select(&:valid?) - - source_branches_removed = pull_requests.reject(&:source_branch_exists?).map { |pr| [pr.source_branch_name, pr.source_branch_sha] } - target_branches_removed = pull_requests.reject(&:target_branch_exists?).map { |pr| [pr.target_branch_name, pr.target_branch_sha] } - branches_removed = source_branches_removed | target_branches_removed - - restore_branches(branches_removed) - - pull_requests.each do |pull_request| - merge_request = pull_request.create! - apply_labels(merge_request) - import_comments(merge_request) - import_comments_on_diff(merge_request) + client.pull_requests(repo, state: :all, sort: :created, direction: :asc, per_page: 100) do |pull_requests| + pull_requests.each do |raw| + pull_request = PullRequestFormatter.new(project, raw) + next unless pull_request.valid? + + begin + restore_source_branch(pull_request) unless pull_request.source_branch_exists? + restore_target_branch(pull_request) unless pull_request.target_branch_exists? + + merge_request = pull_request.create! + apply_labels(merge_request, raw) + rescue => e + errors << { type: :pull_request, url: Gitlab::UrlSanitizer.sanitize(pull_request.url), errors: e.message } + ensure + clean_up_restored_branches(pull_request) + end + end end - true - rescue ActiveRecord::RecordInvalid => e - raise Projects::ImportService::Error, e.message - ensure - clean_up_restored_branches(branches_removed) - clean_up_disabled_webhooks - end - - def disable_webhooks - update_webhooks(hooks, active: false) - end - - def clean_up_disabled_webhooks - update_webhooks(hooks, active: true) + project.repository.after_remove_branch end - def update_webhooks(hooks, options) - hooks.each do |hook| - client.edit_hook(repo, hook.id, hook.name, hook.config, options) - end + def restore_source_branch(pull_request) + project.repository.fetch_ref(repo_url, "pull/#{pull_request.number}/head", pull_request.source_branch_name) end - def hooks - @hooks ||= - begin - client.hooks(repo).map { |raw| HookFormatter.new(raw) }.select(&:valid?) - - # The GitHub Repository Webhooks API returns 404 for users - # without admin access to the repository when listing hooks. - # In this case we just want to return gracefully instead of - # spitting out an error and stop the import process. - rescue Octokit::NotFound - [] - end + def restore_target_branch(pull_request) + project.repository.create_branch(pull_request.target_branch_name, pull_request.target_branch_sha) end - def restore_branches(branches) - branches.each do |name, sha| - client.create_ref(repo, "refs/heads/#{name}", sha) - end - - project.repository.fetch_ref(repo_url, '+refs/heads/*', 'refs/heads/*') + def remove_branch(name) + project.repository.delete_branch(name) + rescue Rugged::ReferenceError + errors << { type: :remove_branch, name: name } end - def clean_up_restored_branches(branches) - branches.each do |name, _| - client.delete_ref(repo, "heads/#{name}") - project.repository.delete_branch(name) rescue Rugged::ReferenceError - end - - project.repository.after_remove_branch + def clean_up_restored_branches(pull_request) + remove_branch(pull_request.source_branch_name) unless pull_request.source_branch_exists? + remove_branch(pull_request.target_branch_name) unless pull_request.target_branch_exists? end - def apply_labels(issuable) - issue = client.issue(repo, issuable.iid) - - if issue.labels.count > 0 - label_ids = issue.labels.map do |raw| - Label.find_by(LabelFormatter.new(project, raw).attributes).try(:id) - end + def apply_labels(issuable, raw_issuable) + if raw_issuable.labels.count > 0 + label_ids = raw_issuable.labels + .map { |attrs| @labels[attrs.name] } + .compact issuable.update_attribute(:label_ids, label_ids) end end - def import_comments(issuable) - comments = client.issue_comments(repo, issuable.iid, per_page: 100) - create_comments(issuable, comments) - end + def import_comments + client.issues_comments(repo, per_page: 100) do |comments| + create_comments(comments, :issue) + end - def import_comments_on_diff(merge_request) - comments = client.pull_request_comments(repo, merge_request.iid, per_page: 100) - create_comments(merge_request, comments) + client.pull_requests_comments(repo, per_page: 100) do |comments| + create_comments(comments, :pull_request) + end end - def create_comments(issuable, comments) - comments.each do |raw| - comment = CommentFormatter.new(project, raw) - issuable.notes.create!(comment.attributes) + def create_comments(comments, issuable_type) + ActiveRecord::Base.no_touching do + comments.each do |raw| + begin + comment = CommentFormatter.new(project, raw) + issuable_class = issuable_type == :issue ? Issue : MergeRequest + iid = raw.send("#{issuable_type}_url").split('/').last # GH doesn't return parent ID directly + issuable = issuable_class.find_by_iid(iid) + next unless issuable + + issuable.notes.create!(comment.attributes) + rescue => e + errors << { type: :comment, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } + end + end end end def import_wiki - unless project.wiki_enabled? + unless project.wiki.repository_exists? wiki = WikiFormatter.new(project) gitlab_shell.import_repository(project.repository_storage_path, wiki.path_with_namespace, wiki.import_url) - project.update_attribute(:wiki_enabled, true) end - - true rescue Gitlab::Shell::Error => e # GitHub error message when the wiki repo has not been created, # this means that repo has wiki enabled, but have no pages. So, # we can skip the import. if e.message !~ /repository not exported/ - raise Projects::ImportService::Error, e.message - else - true + errors << { type: :wiki, errors: e.message } + end + end + + def import_releases + client.releases(repo, per_page: 100) do |releases| + releases.each do |raw| + begin + gh_release = ReleaseFormatter.new(project, raw) + gh_release.create! if gh_release.valid? + rescue => e + errors << { type: :release, url: Gitlab::UrlSanitizer.sanitize(raw.url), errors: e.message } + end + end end end end diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb index 835ec858b35..77621de9f4c 100644 --- a/lib/gitlab/github_import/issue_formatter.rb +++ b/lib/gitlab/github_import/issue_formatter.rb @@ -12,7 +12,7 @@ module Gitlab author_id: author_id, assignee_id: assignee_id, created_at: raw_data.created_at, - updated_at: updated_at + updated_at: raw_data.updated_at } end @@ -40,7 +40,7 @@ module Gitlab def assignee_id if assigned? - gl_user_id(raw_data.assignee.id) + gitlab_user_id(raw_data.assignee.id) end end @@ -49,7 +49,7 @@ module Gitlab end def author_id - gl_user_id(raw_data.user.id) || project.creator_id + gitlab_author_id || project.creator_id end def body @@ -57,7 +57,11 @@ module Gitlab end def description - @formatter.author_line(author) + body + if gitlab_author_id + body + else + formatter.author_line(author) + body + end end def milestone @@ -69,10 +73,6 @@ module Gitlab def state raw_data.state == 'closed' ? 'closed' : 'opened' end - - def updated_at - state == 'closed' ? raw_data.closed_at : raw_data.updated_at - end end end end diff --git a/lib/gitlab/github_import/label_formatter.rb b/lib/gitlab/github_import/label_formatter.rb index 9f18244e7d7..2cad7fca88e 100644 --- a/lib/gitlab/github_import/label_formatter.rb +++ b/lib/gitlab/github_import/label_formatter.rb @@ -13,6 +13,12 @@ module Gitlab Label end + def create! + project.labels.find_or_create_by!(title: title) do |label| + label.color = color + end + end + private def color diff --git a/lib/gitlab/github_import/milestone_formatter.rb b/lib/gitlab/github_import/milestone_formatter.rb index 53d4b3102d1..b2fa524cf5b 100644 --- a/lib/gitlab/github_import/milestone_formatter.rb +++ b/lib/gitlab/github_import/milestone_formatter.rb @@ -3,14 +3,14 @@ module Gitlab class MilestoneFormatter < BaseFormatter def attributes { - iid: number, + iid: raw_data.number, project: project, - title: title, - description: description, - due_date: due_date, + title: raw_data.title, + description: raw_data.description, + due_date: raw_data.due_on, state: state, - created_at: created_at, - updated_at: updated_at + created_at: raw_data.created_at, + updated_at: raw_data.updated_at } end @@ -20,33 +20,9 @@ module Gitlab private - def number - raw_data.number - end - - def title - raw_data.title - end - - def description - raw_data.description - end - - def due_date - raw_data.due_on - end - def state raw_data.state == 'closed' ? 'closed' : 'active' end - - def created_at - raw_data.created_at - end - - def updated_at - state == 'closed' ? raw_data.closed_at : raw_data.updated_at - end end end end diff --git a/lib/gitlab/github_import/project_creator.rb b/lib/gitlab/github_import/project_creator.rb index f4221003db5..a2410068845 100644 --- a/lib/gitlab/github_import/project_creator.rb +++ b/lib/gitlab/github_import/project_creator.rb @@ -1,10 +1,11 @@ module Gitlab module GithubImport class ProjectCreator - attr_reader :repo, :namespace, :current_user, :session_data + attr_reader :repo, :name, :namespace, :current_user, :session_data - def initialize(repo, namespace, current_user, session_data) + def initialize(repo, name, namespace, current_user, session_data) @repo = repo + @name = name @namespace = namespace @current_user = current_user @session_data = session_data @@ -13,17 +14,36 @@ module Gitlab def execute ::Projects::CreateService.new( current_user, - name: repo.name, - path: repo.name, + name: name, + path: name, description: repo.description, namespace_id: namespace.id, - visibility_level: repo.private ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::VisibilityLevel::PUBLIC, + visibility_level: visibility_level, import_type: "github", import_source: repo.full_name, - import_url: repo.clone_url.sub("https://", "https://#{@session_data[:github_access_token]}@"), - wiki_enabled: !repo.has_wiki? # If repo has wiki we'll import it later + import_url: import_url, + skip_wiki: skip_wiki ).execute end + + private + + def import_url + repo.clone_url.sub('https://', "https://#{session_data[:github_access_token]}@") + end + + def visibility_level + repo.private ? Gitlab::VisibilityLevel::PRIVATE : ApplicationSetting.current.default_project_visibility + end + + # + # If the GitHub project repository has wiki, we should not create the + # default wiki. Otherwise the GitHub importer will fail because the wiki + # repository already exist. + # + def skip_wiki + repo.has_wiki? + end end end end diff --git a/lib/gitlab/github_import/pull_request_formatter.rb b/lib/gitlab/github_import/pull_request_formatter.rb index a4ea2210abd..1408683100f 100644 --- a/lib/gitlab/github_import/pull_request_formatter.rb +++ b/lib/gitlab/github_import/pull_request_formatter.rb @@ -1,8 +1,8 @@ module Gitlab module GithubImport class PullRequestFormatter < BaseFormatter - delegate :exists?, :name, :project, :repo, :sha, to: :source_branch, prefix: true - delegate :exists?, :name, :project, :repo, :sha, to: :target_branch, prefix: true + delegate :exists?, :project, :ref, :repo, :sha, to: :source_branch, prefix: true + delegate :exists?, :project, :ref, :repo, :sha, to: :target_branch, prefix: true def attributes { @@ -20,7 +20,7 @@ module Gitlab author_id: author_id, assignee_id: assignee_id, created_at: raw_data.created_at, - updated_at: updated_at + updated_at: raw_data.updated_at } end @@ -33,17 +33,33 @@ module Gitlab end def valid? - source_branch.valid? && target_branch.valid? && !cross_project? + source_branch.valid? && target_branch.valid? end def source_branch @source_branch ||= BranchFormatter.new(project, raw_data.head) end + def source_branch_name + @source_branch_name ||= begin + source_branch_exists? ? source_branch_ref : "pull/#{number}/#{source_branch_ref}" + end + end + def target_branch @target_branch ||= BranchFormatter.new(project, raw_data.base) end + def target_branch_name + @target_branch_name ||= begin + target_branch_exists? ? target_branch_ref : "pull/#{number}/#{target_branch_ref}" + end + end + + def url + raw_data.url + end + private def assigned? @@ -52,7 +68,7 @@ module Gitlab def assignee_id if assigned? - gl_user_id(raw_data.assignee.id) + gitlab_user_id(raw_data.assignee.id) end end @@ -61,19 +77,19 @@ module Gitlab end def author_id - gl_user_id(raw_data.user.id) || project.creator_id + gitlab_author_id || project.creator_id end def body raw_data.body || "" end - def cross_project? - source_branch_repo.id != target_branch_repo.id - end - def description - formatter.author_line(author) + body + if gitlab_author_id + body + else + formatter.author_line(author) + body + end end def milestone @@ -91,15 +107,6 @@ module Gitlab 'opened' end end - - def updated_at - case state - when 'merged' then raw_data.merged_at - when 'closed' then raw_data.closed_at - else - raw_data.updated_at - end - end end end end diff --git a/lib/gitlab/github_import/release_formatter.rb b/lib/gitlab/github_import/release_formatter.rb new file mode 100644 index 00000000000..73d643b00ad --- /dev/null +++ b/lib/gitlab/github_import/release_formatter.rb @@ -0,0 +1,23 @@ +module Gitlab + module GithubImport + class ReleaseFormatter < BaseFormatter + def attributes + { + project: project, + tag: raw_data.tag_name, + description: raw_data.body, + created_at: raw_data.created_at, + updated_at: raw_data.created_at + } + end + + def klass + Release + end + + def valid? + !raw_data.draft + end + end + end +end diff --git a/lib/gitlab/gitlab_import/importer.rb b/lib/gitlab/gitlab_import/importer.rb index 46d40f75be6..e44d7934fda 100644 --- a/lib/gitlab/gitlab_import/importer.rb +++ b/lib/gitlab/gitlab_import/importer.rb @@ -41,7 +41,8 @@ module Gitlab title: issue["title"], state: issue["state"], updated_at: issue["updated_at"], - author_id: gl_user_id(project, issue["author"]["id"]) + author_id: gitlab_user_id(project, issue["author"]["id"]), + confidential: issue["confidential"] ) end end @@ -51,7 +52,7 @@ module Gitlab private - def gl_user_id(project, gitlab_id) + def gitlab_user_id(project, gitlab_id) user = User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'gitlab'", gitlab_id.to_s) (user && user.id) || project.creator_id end diff --git a/lib/gitlab/gitorious_import.rb b/lib/gitlab/gitorious_import.rb deleted file mode 100644 index 8d0132a744c..00000000000 --- a/lib/gitlab/gitorious_import.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Gitlab - module GitoriousImport - GITORIOUS_HOST = "https://gitorious.org" - end -end diff --git a/lib/gitlab/gitorious_import/client.rb b/lib/gitlab/gitorious_import/client.rb deleted file mode 100644 index 99fe5bdebfc..00000000000 --- a/lib/gitlab/gitorious_import/client.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Gitlab - module GitoriousImport - class Client - attr_reader :repo_list - - def initialize(repo_list) - @repo_list = repo_list - end - - def authorize_url(redirect_uri) - "#{GITORIOUS_HOST}/gitlab-import?callback_url=#{redirect_uri}" - end - - def repos - @repos ||= repo_names.map { |full_name| GitoriousImport::Repository.new(full_name) } - end - - def repo(id) - repos.find { |repo| repo.id == id } - end - - private - - def repo_names - repo_list.to_s.split(',').map(&:strip).reject(&:blank?) - end - end - end -end diff --git a/lib/gitlab/gitorious_import/project_creator.rb b/lib/gitlab/gitorious_import/project_creator.rb deleted file mode 100644 index 8e22aa9286d..00000000000 --- a/lib/gitlab/gitorious_import/project_creator.rb +++ /dev/null @@ -1,27 +0,0 @@ -module Gitlab - module GitoriousImport - class ProjectCreator - attr_reader :repo, :namespace, :current_user - - def initialize(repo, namespace, current_user) - @repo = repo - @namespace = namespace - @current_user = current_user - end - - def execute - ::Projects::CreateService.new( - current_user, - name: repo.name, - path: repo.path, - description: repo.description, - namespace_id: namespace.id, - visibility_level: Gitlab::VisibilityLevel::PUBLIC, - import_type: "gitorious", - import_source: repo.full_name, - import_url: repo.import_url - ).execute - end - end - end -end diff --git a/lib/gitlab/gitorious_import/repository.rb b/lib/gitlab/gitorious_import/repository.rb deleted file mode 100644 index c88f1ae358d..00000000000 --- a/lib/gitlab/gitorious_import/repository.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Gitlab - module GitoriousImport - Repository = Struct.new(:full_name) do - def id - Digest::SHA1.hexdigest(full_name) - end - - def namespace - segments.first - end - - def path - segments.last - end - - def name - path.titleize - end - - def description - "" - end - - def import_url - "#{GITORIOUS_HOST}/#{full_name}.git" - end - - private - - def segments - full_name.split('/') - end - end - end -end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index c5a11148d33..2c21804fe7a 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -11,7 +11,6 @@ module Gitlab if current_user gon.current_user_id = current_user.id - gon.api_token = current_user.private_token end end end diff --git a/lib/gitlab/identifier.rb b/lib/gitlab/identifier.rb index 3e5d728f3bc..f8809db21aa 100644 --- a/lib/gitlab/identifier.rb +++ b/lib/gitlab/identifier.rb @@ -5,19 +5,61 @@ module Gitlab def identify(identifier, project, newrev) if identifier.blank? # Local push from gitlab - email = project.commit(newrev).author_email rescue nil - User.find_by(email: email) if email - + identify_using_commit(project, newrev) elsif identifier =~ /\Auser-\d+\Z/ # git push over http - user_id = identifier.gsub("user-", "") - User.find_by(id: user_id) - + identify_using_user(identifier) elsif identifier =~ /\Akey-\d+\Z/ # git push over ssh - key_id = identifier.gsub("key-", "") - Key.find_by(id: key_id).try(:user) + identify_using_ssh_key(identifier) + end + end + + # Tries to identify a user based on a commit SHA. + def identify_using_commit(project, ref) + commit = project.commit(ref) + + return if !commit || !commit.author_email + + email = commit.author_email + + identify_with_cache(:email, email) do + User.find_by(email: email) end end + + # Tries to identify a user based on a user identifier (e.g. "user-123"). + def identify_using_user(identifier) + user_id = identifier.gsub("user-", "") + + identify_with_cache(:user, user_id) do + User.find_by(id: user_id) + end + end + + # Tries to identify a user based on an SSH key identifier (e.g. "key-123"). + def identify_using_ssh_key(identifier) + key_id = identifier.gsub("key-", "") + + identify_with_cache(:ssh_key, key_id) do + User.find_by_ssh_key_id(key_id) + end + end + + def identify_with_cache(category, key) + if identification_cache[category].key?(key) + identification_cache[category][key] + else + identification_cache[category][key] = yield + end + end + + def identification_cache + @identification_cache ||= { + email: {}, + user: {}, + ssh_key: {} + } + end end end diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index 48b2c43ac21..181e288a014 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -2,7 +2,8 @@ module Gitlab module ImportExport extend self - VERSION = '0.1.3' + # For every version update, the version history in import_export.md has to be kept up to date. + VERSION = '0.1.4' FILENAME_LIMIT = 50 def export_path(relative_path:) @@ -13,6 +14,10 @@ module Gitlab File.join(Settings.shared['path'], 'tmp/project_exports') end + def import_upload_path(filename:) + File.join(storage_path, 'uploads', filename) + end + def project_filename "project.json" end diff --git a/lib/gitlab/import_export/attribute_cleaner.rb b/lib/gitlab/import_export/attribute_cleaner.rb new file mode 100644 index 00000000000..b9e4042220a --- /dev/null +++ b/lib/gitlab/import_export/attribute_cleaner.rb @@ -0,0 +1,13 @@ +module Gitlab + module ImportExport + class AttributeCleaner + ALLOWED_REFERENCES = RelationFactory::PROJECT_REFERENCES + RelationFactory::USER_REFERENCES + + def self.clean!(relation_hash:) + relation_hash.reject! do |key, _value| + key.end_with?('_id') && !ALLOWED_REFERENCES.include?(key) + end + end + end + end +end diff --git a/lib/gitlab/import_export/avatar_restorer.rb b/lib/gitlab/import_export/avatar_restorer.rb index 352539eb594..cfa595629f4 100644 --- a/lib/gitlab/import_export/avatar_restorer.rb +++ b/lib/gitlab/import_export/avatar_restorer.rb @@ -1,7 +1,6 @@ module Gitlab module ImportExport class AvatarRestorer - def initialize(project:, shared:) @project = project @shared = shared diff --git a/lib/gitlab/import_export/command_line_util.rb b/lib/gitlab/import_export/command_line_util.rb index e522a0fc8f6..f00c7460e82 100644 --- a/lib/gitlab/import_export/command_line_util.rb +++ b/lib/gitlab/import_export/command_line_util.rb @@ -1,6 +1,8 @@ module Gitlab module ImportExport module CommandLineUtil + DEFAULT_MODE = 0700 + def tar_czf(archive:, dir:) tar_with_options(archive: archive, dir: dir, options: 'czf') end @@ -21,6 +23,11 @@ module Gitlab execute(%W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args) end + def mkdir_p(path) + FileUtils.mkdir_p(path, mode: DEFAULT_MODE) + FileUtils.chmod(DEFAULT_MODE, path) + end + private def tar_with_options(archive:, dir:, options:) @@ -45,7 +52,7 @@ module Gitlab # if we are copying files, create the destination folder destination_folder = File.file?(source) ? File.dirname(destination) : destination - FileUtils.mkdir_p(destination_folder) + mkdir_p(destination_folder) FileUtils.copy_entry(source, destination) true end diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb index eca6e5b6d51..113895ba22c 100644 --- a/lib/gitlab/import_export/file_importer.rb +++ b/lib/gitlab/import_export/file_importer.rb @@ -15,7 +15,7 @@ module Gitlab end def import - FileUtils.mkdir_p(@shared.export_path) + mkdir_p(@shared.export_path) wait_for_archived_file do decompress_archive diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 1da51043611..bb9d1080330 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -1,5 +1,8 @@ # Model relationships to be included in the project import/export project_tree: + - :labels + - milestones: + - :events - issues: - :events - notes: @@ -10,6 +13,7 @@ project_tree: - milestone: - :events - snippets: + - :award_emoji - notes: :author - :releases @@ -35,19 +39,15 @@ project_tree: - :deploy_keys - :services - :hooks - - :protected_branches - - :labels - - milestones: - - :events + - protected_branches: + - :merge_access_levels + - :push_access_levels + - :project_feature # Only include the following attributes for the models specified. included_attributes: project: - :description - - :issues_enabled - - :merge_requests_enabled - - :wiki_enabled - - :snippets_enabled - :visibility_level - :archived user: @@ -67,9 +67,13 @@ excluded_attributes: - :milestone_id merge_requests: - :milestone_id + award_emoji: + - :awardable_id methods: statuses: - :type + services: + - :type merge_request_diff: - - :utf8_st_diffs
\ No newline at end of file + - :utf8_st_diffs diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb index 008300bde45..0cc10f40087 100644 --- a/lib/gitlab/import_export/json_hash_builder.rb +++ b/lib/gitlab/import_export/json_hash_builder.rb @@ -57,19 +57,16 @@ module Gitlab # +value+ existing model to be included in the hash # +json_config_hash+ the original hash containing the root model def create_model_value(current_key, value, json_config_hash) - parsed_hash = { include: value } - parse_hash(value, parsed_hash) - - json_config_hash[current_key] = parsed_hash + json_config_hash[current_key] = parse_hash(value) || { include: value } end # Calls attributes finder to parse the hash and add any attributes to it # # +value+ existing model to be included in the hash # +parsed_hash+ the original hash - def parse_hash(value, parsed_hash) + def parse_hash(value) @attributes_finder.parse(value) do |hash| - parsed_hash = { include: hash_or_merge(value, hash) } + { include: hash_or_merge(value, hash) } end end diff --git a/lib/gitlab/import_export/members_mapper.rb b/lib/gitlab/import_export/members_mapper.rb index b459054c198..36c4cf6efa0 100644 --- a/lib/gitlab/import_export/members_mapper.rb +++ b/lib/gitlab/import_export/members_mapper.rb @@ -18,11 +18,14 @@ module Gitlab @map ||= begin @exported_members.inject(missing_keys_tracking_hash) do |hash, member| - existing_user = User.where(find_project_user_query(member)).first - old_user_id = member['user']['id'] - if existing_user && add_user_as_team_member(existing_user, member) - hash[old_user_id] = existing_user.id + if member['user'] + old_user_id = member['user']['id'] + existing_user = User.where(find_project_user_query(member)).first + hash[old_user_id] = existing_user.id if existing_user && add_team_member(member, existing_user) + else + add_team_member(member) end + hash end end @@ -45,7 +48,7 @@ module Gitlab ProjectMember.create!(user: @user, access_level: ProjectMember::MASTER, source_id: @project.id, importing: true) end - def add_user_as_team_member(existing_user, member) + def add_team_member(member, existing_user = nil) member['user'] = existing_user ProjectMember.create(member_hash(member)).persisted? diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index c7b3551b84c..5a109f24f9f 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -61,11 +61,17 @@ module Gitlab def restore_project return @project unless @tree_hash - project_params = @tree_hash.reject { |_key, value| value.is_a?(Array) } @project.update(project_params) @project end + def project_params + @tree_hash.reject do |key, value| + # return params that are not 1 to many or 1 to 1 relations + value.is_a?(Array) || key == key.singularize + end + end + # Given a relation hash containing one or more models and its relationships, # loops through each model and each object from a model type and # and assigns its correspondent attributes hash from +tree_hash+ @@ -104,9 +110,10 @@ module Gitlab def create_relation(relation, relation_hash_list) relation_array = [relation_hash_list].flatten.map do |relation_hash| Gitlab::ImportExport::RelationFactory.create(relation_sym: relation.to_sym, - relation_hash: relation_hash.merge('project_id' => restored_project.id), + relation_hash: relation_hash, members_mapper: members_mapper, - user: @user) + user: @user, + project_id: restored_project.id) end relation_hash_list.is_a?(Array) ? relation_array : relation_array.first diff --git a/lib/gitlab/import_export/project_tree_saver.rb b/lib/gitlab/import_export/project_tree_saver.rb index 9153088e966..2fbf437ec26 100644 --- a/lib/gitlab/import_export/project_tree_saver.rb +++ b/lib/gitlab/import_export/project_tree_saver.rb @@ -1,6 +1,8 @@ module Gitlab module ImportExport class ProjectTreeSaver + include Gitlab::ImportExport::CommandLineUtil + attr_reader :full_path def initialize(project:, shared:) @@ -10,7 +12,7 @@ module Gitlab end def save - FileUtils.mkdir_p(@shared.export_path) + mkdir_p(@shared.export_path) File.write(full_path, project_json_tree) true diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 5e56b3d1aa7..9300f789e1b 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -7,23 +7,29 @@ module Gitlab variables: 'Ci::Variable', triggers: 'Ci::Trigger', builds: 'Ci::Build', - hooks: 'ProjectHook' }.freeze + hooks: 'ProjectHook', + merge_access_levels: 'ProtectedBranch::MergeAccessLevel', + push_access_levels: 'ProtectedBranch::PushAccessLevel' }.freeze USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id].freeze + PROJECT_REFERENCES = %w[project_id source_project_id gl_project_id target_project_id].freeze + BUILD_MODELS = %w[Ci::Build commit_status].freeze IMPORTED_OBJECT_MAX_RETRIES = 5.freeze EXISTING_OBJECT_CHECK = %i[milestone milestones label labels].freeze + FINDER_ATTRIBUTES = %w[title project_id].freeze + def self.create(*args) new(*args).create end - def initialize(relation_sym:, relation_hash:, members_mapper:, user:) + def initialize(relation_sym:, relation_hash:, members_mapper:, user:, project_id:) @relation_name = OVERRIDES[relation_sym] || relation_sym - @relation_hash = relation_hash.except('id', 'noteable_id') + @relation_hash = relation_hash.except('id', 'noteable_id').merge('project_id' => project_id) @members_mapper = members_mapper @user = user @imported_object_retries = 0 @@ -102,17 +108,19 @@ module Gitlab def update_project_references project_id = @relation_hash.delete('project_id') + # If source and target are the same, populate them with the new project ID. + if @relation_hash['source_project_id'] + @relation_hash['source_project_id'] = same_source_and_target? ? project_id : -1 + end + # project_id may not be part of the export, but we always need to populate it if required. @relation_hash['project_id'] = project_id @relation_hash['gl_project_id'] = project_id if @relation_hash['gl_project_id'] @relation_hash['target_project_id'] = project_id if @relation_hash['target_project_id'] - @relation_hash['source_project_id'] = -1 if @relation_hash['source_project_id'] + end - # If source and target are the same, populate them with the new project ID. - if @relation_hash['source_project_id'] && @relation_hash['target_project_id'] && - @relation_hash['target_project_id'] == @relation_hash['source_project_id'] - @relation_hash['source_project_id'] = project_id - end + def same_source_and_target? + @relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id'] end def reset_ci_tokens @@ -147,7 +155,11 @@ module Gitlab end def parsed_relation_hash - @relation_hash.reject { |k, _v| !relation_class.attribute_method?(k) } + @parsed_relation_hash ||= begin + Gitlab::ImportExport::AttributeCleaner.clean!(relation_hash: @relation_hash) + + @relation_hash.reject { |k, _v| !relation_class.attribute_method?(k) } + end end def set_st_diffs @@ -159,14 +171,30 @@ module Gitlab # Otherwise always create the record, skipping the extra SELECT clause. @existing_or_new_object ||= begin if EXISTING_OBJECT_CHECK.include?(@relation_name) - existing_object = relation_class.find_or_initialize_by(parsed_relation_hash.slice('title', 'project_id')) - existing_object.assign_attributes(parsed_relation_hash) + events = parsed_relation_hash.delete('events') + + unless events.blank? + existing_object.assign_attributes(events: events) + end + existing_object else relation_class.new(parsed_relation_hash) end end end + + def existing_object + @existing_object ||= + begin + finder_hash = parsed_relation_hash.slice(*FINDER_ATTRIBUTES) + existing_object = relation_class.find_or_create_by(finder_hash) + # Done in two steps, as MySQL behaves differently than PostgreSQL using + # the +find_or_create_by+ method and does not return the ID the second time. + existing_object.update(parsed_relation_hash) + existing_object + end + end end end end diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb index 6d9379acf25..48a9a6fa5e2 100644 --- a/lib/gitlab/import_export/repo_restorer.rb +++ b/lib/gitlab/import_export/repo_restorer.rb @@ -12,7 +12,7 @@ module Gitlab def restore return true unless File.exist?(@path_to_bundle) - FileUtils.mkdir_p(path_to_repo) + mkdir_p(path_to_repo) git_unbundle(repo_path: path_to_repo, bundle_path: @path_to_bundle) && repo_restore_hooks rescue => e @@ -22,10 +22,6 @@ module Gitlab private - def repos_path - Gitlab.config.gitlab_shell.repos_path - end - def path_to_repo @project.repository.path_to_repo end diff --git a/lib/gitlab/import_export/repo_saver.rb b/lib/gitlab/import_export/repo_saver.rb index 331e14021e6..a7028a32570 100644 --- a/lib/gitlab/import_export/repo_saver.rb +++ b/lib/gitlab/import_export/repo_saver.rb @@ -20,7 +20,7 @@ module Gitlab private def bundle_to_disk - FileUtils.mkdir_p(@shared.export_path) + mkdir_p(@shared.export_path) git_bundle(repo_path: path_to_repo, bundle_path: @full_path) rescue => e @shared.error(e) diff --git a/lib/gitlab/import_export/version_checker.rb b/lib/gitlab/import_export/version_checker.rb index de3fe6d822e..fc08082fc86 100644 --- a/lib/gitlab/import_export/version_checker.rb +++ b/lib/gitlab/import_export/version_checker.rb @@ -24,8 +24,8 @@ module Gitlab end def verify_version!(version) - if Gem::Version.new(version) > Gem::Version.new(Gitlab::ImportExport.version) - raise Gitlab::ImportExport::Error.new("Import version mismatch: Required <= #{Gitlab::ImportExport.version} but was #{version}") + if Gem::Version.new(version) != Gem::Version.new(Gitlab::ImportExport.version) + raise Gitlab::ImportExport::Error.new("Import version mismatch: Required #{Gitlab::ImportExport.version} but was #{version}") else true end diff --git a/lib/gitlab/import_export/version_saver.rb b/lib/gitlab/import_export/version_saver.rb index 9b642d740b7..7cf88298642 100644 --- a/lib/gitlab/import_export/version_saver.rb +++ b/lib/gitlab/import_export/version_saver.rb @@ -1,12 +1,14 @@ module Gitlab module ImportExport class VersionSaver + include Gitlab::ImportExport::CommandLineUtil + def initialize(shared:) @shared = shared end def save - FileUtils.mkdir_p(@shared.export_path) + mkdir_p(@shared.export_path) File.write(version_file, Gitlab::ImportExport.version, mode: 'w') rescue => e diff --git a/lib/gitlab/import_export/wiki_repo_saver.rb b/lib/gitlab/import_export/wiki_repo_saver.rb index 6107420e4dd..1e6722a7bba 100644 --- a/lib/gitlab/import_export/wiki_repo_saver.rb +++ b/lib/gitlab/import_export/wiki_repo_saver.rb @@ -9,7 +9,7 @@ module Gitlab end def bundle_to_disk(full_path) - FileUtils.mkdir_p(@shared.export_path) + mkdir_p(@shared.export_path) git_bundle(repo_path: path_to_repo, bundle_path: full_path) rescue => e @shared.error(e) diff --git a/lib/gitlab/import_sources.rb b/lib/gitlab/import_sources.rb index 59a05411fe9..94261b7eeed 100644 --- a/lib/gitlab/import_sources.rb +++ b/lib/gitlab/import_sources.rb @@ -14,13 +14,12 @@ module Gitlab def options { - 'GitHub' => 'github', - 'Bitbucket' => 'bitbucket', - 'GitLab.com' => 'gitlab', - 'Gitorious.org' => 'gitorious', - 'Google Code' => 'google_code', - 'FogBugz' => 'fogbugz', - 'Repo by URL' => 'git', + 'GitHub' => 'github', + 'Bitbucket' => 'bitbucket', + 'GitLab.com' => 'gitlab', + 'Google Code' => 'google_code', + 'FogBugz' => 'fogbugz', + 'Repo by URL' => 'git', 'GitLab export' => 'gitlab_project' } end diff --git a/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb index f2b649e50a2..7e06bd2b0fb 100644 --- a/lib/gitlab/ldap/access.rb +++ b/lib/gitlab/ldap/access.rb @@ -25,7 +25,7 @@ module Gitlab end end - def initialize(user, adapter=nil) + def initialize(user, adapter = nil) @adapter = adapter @user = user @provider = user.ldap_identity.provider @@ -51,8 +51,6 @@ module Gitlab user.ldap_block false end - rescue - false end def adapter diff --git a/lib/gitlab/ldap/adapter.rb b/lib/gitlab/ldap/adapter.rb index df65179bfea..8b38cfaefb6 100644 --- a/lib/gitlab/ldap/adapter.rb +++ b/lib/gitlab/ldap/adapter.rb @@ -13,7 +13,7 @@ module Gitlab Gitlab::LDAP::Config.new(provider) end - def initialize(provider, ldap=nil) + def initialize(provider, ldap = nil) @provider = provider @ldap = ldap || Net::LDAP.new(config.adapter_options) end @@ -23,31 +23,7 @@ module Gitlab end def users(field, value, limit = nil) - if field.to_sym == :dn - options = { - base: value, - scope: Net::LDAP::SearchScope_BaseObject - } - else - options = { - base: config.base, - filter: Net::LDAP::Filter.eq(field, value) - } - end - - if config.user_filter.present? - user_filter = Net::LDAP::Filter.construct(config.user_filter) - - options[:filter] = if options[:filter] - Net::LDAP::Filter.join(options[:filter], user_filter) - else - user_filter - end - end - - if limit.present? - options.merge!(size: limit) - end + options = user_options(field, value, limit) entries = ldap_search(options).select do |entry| entry.respond_to? config.uid @@ -86,10 +62,49 @@ module Gitlab results end end + rescue Net::LDAP::Error => error + Rails.logger.warn("LDAP search raised exception #{error.class}: #{error.message}") + [] rescue Timeout::Error Rails.logger.warn("LDAP search timed out after #{config.timeout} seconds") [] end + + private + + def user_options(field, value, limit) + options = { attributes: user_attributes } + options[:size] = limit if limit + + if field.to_sym == :dn + options[:base] = value + options[:scope] = Net::LDAP::SearchScope_BaseObject + options[:filter] = user_filter + else + options[:base] = config.base + options[:filter] = user_filter(Net::LDAP::Filter.eq(field, value)) + end + + options + end + + def user_filter(filter = nil) + if config.user_filter.present? + user_filter = Net::LDAP::Filter.construct(config.user_filter) + end + + if user_filter && filter + Net::LDAP::Filter.join(filter, user_filter) + elsif user_filter + user_filter + else + filter + end + end + + def user_attributes + %W(#{config.uid} cn mail dn) + end end end end diff --git a/lib/gitlab/lfs/response.rb b/lib/gitlab/lfs/response.rb deleted file mode 100644 index a1ee1aa81ff..00000000000 --- a/lib/gitlab/lfs/response.rb +++ /dev/null @@ -1,329 +0,0 @@ -module Gitlab - module Lfs - class Response - def initialize(project, user, ci, request) - @origin_project = project - @project = storage_project(project) - @user = user - @ci = ci - @env = request.env - @request = request - end - - def render_download_object_response(oid) - render_response_to_download do - if check_download_sendfile_header? - render_lfs_sendfile(oid) - else - render_not_found - end - end - end - - def render_batch_operation_response - request_body = JSON.parse(@request.body.read) - case request_body["operation"] - when "download" - render_batch_download(request_body) - when "upload" - render_batch_upload(request_body) - else - render_not_found - end - end - - def render_storage_upload_authorize_response(oid, size) - render_response_to_push do - [ - 200, - { "Content-Type" => "application/json; charset=utf-8" }, - [JSON.dump({ - 'StoreLFSPath' => "#{Gitlab.config.lfs.storage_path}/tmp/upload", - 'LfsOid' => oid, - 'LfsSize' => size - })] - ] - end - end - - def render_storage_upload_store_response(oid, size, tmp_file_name) - return render_forbidden unless tmp_file_name - - render_response_to_push do - render_lfs_upload_ok(oid, size, tmp_file_name) - end - end - - def render_unsupported_deprecated_api - [ - 501, - { "Content-Type" => "application/json; charset=utf-8" }, - [JSON.dump({ - 'message' => 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', - 'documentation_url' => "#{Gitlab.config.gitlab.url}/help", - })] - ] - end - - private - - def render_not_enabled - [ - 501, - { - "Content-Type" => "application/json; charset=utf-8", - }, - [JSON.dump({ - 'message' => 'Git LFS is not enabled on this GitLab server, contact your admin.', - 'documentation_url' => "#{Gitlab.config.gitlab.url}/help", - })] - ] - end - - def render_unauthorized - [ - 401, - { - 'Content-Type' => 'text/plain' - }, - ['Unauthorized'] - ] - end - - def render_not_found - [ - 404, - { - "Content-Type" => "application/vnd.git-lfs+json" - }, - [JSON.dump({ - 'message' => 'Not found.', - 'documentation_url' => "#{Gitlab.config.gitlab.url}/help", - })] - ] - end - - def render_forbidden - [ - 403, - { - "Content-Type" => "application/vnd.git-lfs+json" - }, - [JSON.dump({ - 'message' => 'Access forbidden. Check your access level.', - 'documentation_url' => "#{Gitlab.config.gitlab.url}/help", - })] - ] - end - - def render_lfs_sendfile(oid) - return render_not_found unless oid.present? - - lfs_object = object_for_download(oid) - - if lfs_object && lfs_object.file.exists? - [ - 200, - { - # GitLab-workhorse will forward Content-Type header - "Content-Type" => "application/octet-stream", - "X-Sendfile" => lfs_object.file.path - }, - [] - ] - else - render_not_found - end - end - - def render_batch_upload(body) - return render_not_found if body.empty? || body['objects'].nil? - - render_response_to_push do - response = build_upload_batch_response(body['objects']) - [ - 200, - { - "Content-Type" => "application/json; charset=utf-8", - "Cache-Control" => "private", - }, - [JSON.dump(response)] - ] - end - end - - def render_batch_download(body) - return render_not_found if body.empty? || body['objects'].nil? - - render_response_to_download do - response = build_download_batch_response(body['objects']) - [ - 200, - { - "Content-Type" => "application/json; charset=utf-8", - "Cache-Control" => "private", - }, - [JSON.dump(response)] - ] - end - end - - def render_lfs_upload_ok(oid, size, tmp_file) - if store_file(oid, size, tmp_file) - [ - 200, - { - 'Content-Type' => 'text/plain', - 'Content-Length' => 0 - }, - [] - ] - else - [ - 422, - { 'Content-Type' => 'text/plain' }, - ["Unprocessable entity"] - ] - end - end - - def render_response_to_download - return render_not_enabled unless Gitlab.config.lfs.enabled - - unless @project.public? - return render_unauthorized unless @user || @ci - return render_forbidden unless user_can_fetch? - end - - yield - end - - def render_response_to_push - return render_not_enabled unless Gitlab.config.lfs.enabled - return render_unauthorized unless @user - return render_forbidden unless user_can_push? - - yield - end - - def check_download_sendfile_header? - @env['HTTP_X_SENDFILE_TYPE'].to_s == "X-Sendfile" - end - - def user_can_fetch? - # Check user access against the project they used to initiate the pull - @ci || @user.can?(:download_code, @origin_project) - end - - def user_can_push? - # Check user access against the project they used to initiate the push - @user.can?(:push_code, @origin_project) - end - - def storage_project(project) - if project.forked? - storage_project(project.forked_from_project) - else - project - end - end - - def store_file(oid, size, tmp_file) - tmp_file_path = File.join("#{Gitlab.config.lfs.storage_path}/tmp/upload", tmp_file) - - object = LfsObject.find_or_create_by(oid: oid, size: size) - if object.file.exists? - success = true - else - success = move_tmp_file_to_storage(object, tmp_file_path) - end - - if success - success = link_to_project(object) - end - - success - ensure - # Ensure that the tmp file is removed - FileUtils.rm_f(tmp_file_path) - end - - def object_for_download(oid) - @project.lfs_objects.find_by(oid: oid) - end - - def move_tmp_file_to_storage(object, path) - File.open(path) do |f| - object.file = f - end - - object.file.store! - object.save - end - - def link_to_project(object) - if object && !object.projects.exists?(@project.id) - object.projects << @project - object.save - end - end - - def select_existing_objects(objects) - objects_oids = objects.map { |o| o['oid'] } - @project.lfs_objects.where(oid: objects_oids).pluck(:oid).to_set - end - - def build_upload_batch_response(objects) - selected_objects = select_existing_objects(objects) - - upload_hypermedia_links(objects, selected_objects) - end - - def build_download_batch_response(objects) - selected_objects = select_existing_objects(objects) - - download_hypermedia_links(objects, selected_objects) - end - - def download_hypermedia_links(all_objects, existing_objects) - all_objects.each do |object| - if existing_objects.include?(object['oid']) - object['actions'] = { - 'download' => { - 'href' => "#{@origin_project.http_url_to_repo}/gitlab-lfs/objects/#{object['oid']}", - 'header' => { - 'Authorization' => @env['HTTP_AUTHORIZATION'] - }.compact - } - } - else - object['error'] = { - 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", - } - end - end - - { 'objects' => all_objects } - end - - def upload_hypermedia_links(all_objects, existing_objects) - all_objects.each do |object| - # generate actions only for non-existing objects - next if existing_objects.include?(object['oid']) - - object['actions'] = { - 'upload' => { - 'href' => "#{@origin_project.http_url_to_repo}/gitlab-lfs/objects/#{object['oid']}/#{object['size']}", - 'header' => { - 'Authorization' => @env['HTTP_AUTHORIZATION'] - }.compact - } - } - end - - { 'objects' => all_objects } - end - end - end -end diff --git a/lib/gitlab/lfs/router.rb b/lib/gitlab/lfs/router.rb deleted file mode 100644 index f2a76a56b8f..00000000000 --- a/lib/gitlab/lfs/router.rb +++ /dev/null @@ -1,98 +0,0 @@ -module Gitlab - module Lfs - class Router - attr_reader :project, :user, :ci, :request - - def initialize(project, user, ci, request) - @project = project - @user = user - @ci = ci - @env = request.env - @request = request - end - - def try_call - return unless @request && @request.path.present? - - case @request.request_method - when 'GET' - get_response - when 'POST' - post_response - when 'PUT' - put_response - else - nil - end - end - - private - - def get_response - path_match = @request.path.match(/\/(info\/lfs|gitlab-lfs)\/objects\/([0-9a-f]{64})$/) - return nil unless path_match - - oid = path_match[2] - return nil unless oid - - case path_match[1] - when "info/lfs" - lfs.render_unsupported_deprecated_api - when "gitlab-lfs" - lfs.render_download_object_response(oid) - else - nil - end - end - - def post_response - post_path = @request.path.match(/\/info\/lfs\/objects(\/batch)?$/) - return nil unless post_path - - # Check for Batch API - if post_path[0].ends_with?("/info/lfs/objects/batch") - lfs.render_batch_operation_response - elsif post_path[0].ends_with?("/info/lfs/objects") - lfs.render_unsupported_deprecated_api - else - nil - end - end - - def put_response - object_match = @request.path.match(/\/gitlab-lfs\/objects\/([0-9a-f]{64})\/([0-9]+)(|\/authorize){1}$/) - return nil if object_match.nil? - - oid = object_match[1] - size = object_match[2].try(:to_i) - return nil if oid.nil? || size.nil? - - # GitLab-workhorse requests - # 1. Try to authorize the request - # 2. send a request with a header containing the name of the temporary file - if object_match[3] && object_match[3] == '/authorize' - lfs.render_storage_upload_authorize_response(oid, size) - else - tmp_file_name = sanitize_tmp_filename(@request.env['HTTP_X_GITLAB_LFS_TMP']) - lfs.render_storage_upload_store_response(oid, size, tmp_file_name) - end - end - - def lfs - return unless @project - - Gitlab::Lfs::Response.new(@project, @user, @ci, @request) - end - - def sanitize_tmp_filename(name) - if name.present? - name.gsub!(/^.*(\\|\/)/, '') - name = name.match(/[0-9a-f]{73}/) - name[0] if name - else - nil - end - end - end - end -end diff --git a/lib/gitlab/lfs_token.rb b/lib/gitlab/lfs_token.rb new file mode 100644 index 00000000000..5f67e97fa2a --- /dev/null +++ b/lib/gitlab/lfs_token.rb @@ -0,0 +1,48 @@ +module Gitlab + class LfsToken + attr_accessor :actor + + TOKEN_LENGTH = 50 + EXPIRY_TIME = 1800 + + def initialize(actor) + @actor = + case actor + when DeployKey, User + actor + when Key + actor.user + else + raise 'Bad Actor' + end + end + + def token + Gitlab::Redis.with do |redis| + token = redis.get(redis_key) + token ||= Devise.friendly_token(TOKEN_LENGTH) + redis.set(redis_key, token, ex: EXPIRY_TIME) + + token + end + end + + def user? + actor.is_a?(User) + end + + def type + actor.is_a?(User) ? :lfs_token : :lfs_deploy_token + end + + def actor_name + actor.is_a?(User) ? actor.username : "lfs+deploy-key-#{actor.id}" + end + + private + + def redis_key + "gitlab:lfs_token:#{actor.class.name.underscore}_#{actor.id}" if actor + end + end +end diff --git a/lib/gitlab/mail_room.rb b/lib/gitlab/mail_room.rb new file mode 100644 index 00000000000..12999a90a29 --- /dev/null +++ b/lib/gitlab/mail_room.rb @@ -0,0 +1,47 @@ +require 'yaml' +require 'json' +require_relative 'redis' unless defined?(Gitlab::Redis) + +module Gitlab + module MailRoom + class << self + def enabled? + config[:enabled] && config[:address] + end + + def config + @config ||= fetch_config + end + + def reset_config! + @config = nil + end + + private + + def fetch_config + return {} unless File.exist?(config_file) + + rails_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development' + all_config = YAML.load_file(config_file)[rails_env].deep_symbolize_keys + + config = all_config[:incoming_email] || {} + config[:enabled] = false if config[:enabled].nil? + config[:port] = 143 if config[:port].nil? + config[:ssl] = false if config[:ssl].nil? + config[:start_tls] = false if config[:start_tls].nil? + config[:mailbox] = 'inbox' if config[:mailbox].nil? + + if config[:enabled] && config[:address] + config[:redis_url] = Gitlab::Redis.new(rails_env).url + end + + config + end + + def config_file + ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] || File.expand_path('../../../config/gitlab.yml', __FILE__) + end + end + end +end diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb index 41fcd971c22..3d1ba33ec68 100644 --- a/lib/gitlab/metrics.rb +++ b/lib/gitlab/metrics.rb @@ -124,6 +124,15 @@ module Gitlab trans.action = action if trans end + # Tracks an event. + # + # See `Gitlab::Metrics::Transaction#add_event` for more details. + def self.add_event(*args) + trans = current_transaction + + trans.add_event(*args) if trans + end + # Returns the prefix to use for the name of a series. def self.series_prefix @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' diff --git a/lib/gitlab/metrics/metric.rb b/lib/gitlab/metrics/metric.rb index f23d67e1e38..bd0afe53c51 100644 --- a/lib/gitlab/metrics/metric.rb +++ b/lib/gitlab/metrics/metric.rb @@ -4,15 +4,20 @@ module Gitlab class Metric JITTER_RANGE = 0.000001..0.001 - attr_reader :series, :values, :tags + attr_reader :series, :values, :tags, :type # series - The name of the series (as a String) to store the metric in. # values - A Hash containing the values to store. # tags - A Hash containing extra tags to add to the metrics. - def initialize(series, values, tags = {}) + def initialize(series, values, tags = {}, type = :metric) @values = values @series = series @tags = tags + @type = type + end + + def event? + type == :event end # Returns a Hash in a format that can be directly written to InfluxDB. diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index e61670f491c..01c96a6fe96 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -4,6 +4,17 @@ module Gitlab class RackMiddleware CONTROLLER_KEY = 'action_controller.instance' ENDPOINT_KEY = 'api.endpoint' + CONTENT_TYPES = { + 'text/html' => :html, + 'text/plain' => :txt, + 'application/json' => :json, + 'text/js' => :js, + 'application/atom+xml' => :atom, + 'image/png' => :png, + 'image/jpeg' => :jpeg, + 'image/gif' => :gif, + 'image/svg+xml' => :svg + } def initialize(app) @app = app @@ -17,6 +28,10 @@ module Gitlab begin retval = trans.run { @app.call(env) } + rescue Exception => error # rubocop: disable Lint/RescueException + trans.add_event(:rails_exception) + + raise error # Even in the event of an error we want to submit any metrics we # might've gathered up to this point. ensure @@ -42,8 +57,15 @@ module Gitlab end def tag_controller(trans, env) - controller = env[CONTROLLER_KEY] - trans.action = "#{controller.class.name}##{controller.action_name}" + controller = env[CONTROLLER_KEY] + action = "#{controller.class.name}##{controller.action_name}" + suffix = CONTENT_TYPES[controller.content_type] + + if suffix && suffix != :html + action += ".#{suffix}" + end + + trans.action = action end def tag_endpoint(trans, env) diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb index a1240fd33ee..f9dd8e41912 100644 --- a/lib/gitlab/metrics/sidekiq_middleware.rb +++ b/lib/gitlab/metrics/sidekiq_middleware.rb @@ -11,6 +11,10 @@ module Gitlab # Old gitlad-shell messages don't provide enqueued_at/created_at attributes trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0)) trans.run { yield } + rescue Exception => error # rubocop: disable Lint/RescueException + trans.add_event(:sidekiq_exception) + + raise error ensure trans.finish end diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index 968f3218950..7bc16181be6 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -4,7 +4,10 @@ module Gitlab class Transaction THREAD_KEY = :_gitlab_metrics_transaction - attr_reader :tags, :values, :methods + # The series to store events (e.g. Git pushes) in. + EVENT_SERIES = 'events' + + attr_reader :tags, :values, :method, :metrics attr_accessor :action @@ -55,6 +58,20 @@ module Gitlab @metrics << Metric.new("#{Metrics.series_prefix}#{series}", values, tags) end + # Tracks a business level event + # + # Business level events including events such as Git pushes, Emails being + # sent, etc. + # + # event_name - The name of the event (e.g. "git_push"). + # tags - A set of tags to attach to the event. + def add_event(event_name, tags = {}) + @metrics << Metric.new(EVENT_SERIES, + { count: 1 }, + { event: event_name }.merge(tags), + :event) + end + # Returns a MethodCall object for the given name. def method_call_for(name) unless method = @methods[name] @@ -101,7 +118,7 @@ module Gitlab submit_hashes = submit.map do |metric| hash = metric.to_hash - hash[:tags][:action] ||= @action if @action + hash[:tags][:action] ||= @action if @action && !metric.event? hash end diff --git a/lib/gitlab/middleware/rails_queue_duration.rb b/lib/gitlab/middleware/rails_queue_duration.rb index 56608b1b276..5d2d7d0026c 100644 --- a/lib/gitlab/middleware/rails_queue_duration.rb +++ b/lib/gitlab/middleware/rails_queue_duration.rb @@ -11,7 +11,7 @@ module Gitlab def call(env) trans = Gitlab::Metrics.current_transaction - proxy_start = env['HTTP_GITLAB_WORHORSE_PROXY_START'].presence + proxy_start = env['HTTP_GITLAB_WORKHORSE_PROXY_START'].presence if trans && proxy_start # Time in milliseconds since gitlab-workhorse started the request trans.set(:rails_queue_duration, Time.now.to_f * 1_000 - proxy_start.to_f / 1_000_000) diff --git a/lib/gitlab/popen.rb b/lib/gitlab/popen.rb index 43e07e09160..cc74bb29087 100644 --- a/lib/gitlab/popen.rb +++ b/lib/gitlab/popen.rb @@ -5,7 +5,7 @@ module Gitlab module Popen extend self - def popen(cmd, path=nil) + def popen(cmd, path = nil) unless cmd.is_a?(Array) raise "System commands must be given as an array of strings" end @@ -18,18 +18,18 @@ module Gitlab FileUtils.mkdir_p(path) end - @cmd_output = "" - @cmd_status = 0 + cmd_output = "" + cmd_status = 0 Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr| - # We are not using stdin so we should close it, in case the command we - # are running waits for input. + yield(stdin) if block_given? stdin.close - @cmd_output << stdout.read - @cmd_output << stderr.read - @cmd_status = wait_thr.value.exitstatus + + cmd_output << stdout.read + cmd_output << stderr.read + cmd_status = wait_thr.value.exitstatus end - [@cmd_output, @cmd_status] + [cmd_output, cmd_status] end end end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 183bd10d6a3..5b9cfaeb2f8 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -28,11 +28,6 @@ module Gitlab end end - def total_count - @total_count ||= issues_count + merge_requests_count + blobs_count + - notes_count + wiki_blobs_count + commits_count - end - def blobs_count @blobs_count ||= blobs.count end diff --git a/lib/gitlab/redis.rb b/lib/gitlab/redis.rb index 40766f35f77..c649da8c426 100644 --- a/lib/gitlab/redis.rb +++ b/lib/gitlab/redis.rb @@ -1,50 +1,99 @@ +# This file should not have any direct dependency on Rails environment +# please require all dependencies below: +require 'active_support/core_ext/hash/keys' + module Gitlab class Redis CACHE_NAMESPACE = 'cache:gitlab' SESSION_NAMESPACE = 'session:gitlab' SIDEKIQ_NAMESPACE = 'resque:gitlab' + MAILROOM_NAMESPACE = 'mail_room:gitlab' + DEFAULT_REDIS_URL = 'redis://localhost:6379' + CONFIG_FILE = File.expand_path('../../config/resque.yml', __dir__) - attr_reader :url + class << self + # Do NOT cache in an instance variable. Result may be mutated by caller. + def params + new.params + end - # To be thread-safe we must be careful when writing the class instance - # variables @url and @pool. Because @pool depends on @url we need two - # mutexes to prevent deadlock. - URL_MUTEX = Mutex.new - POOL_MUTEX = Mutex.new - private_constant :URL_MUTEX, :POOL_MUTEX + # Do NOT cache in an instance variable. Result may be mutated by caller. + # @deprecated Use .params instead to get sentinel support + def url + new.url + end - def self.url - @url || URL_MUTEX.synchronize { @url = new.url } - end + def with + @pool ||= ConnectionPool.new(size: pool_size) { ::Redis.new(params) } + @pool.with { |redis| yield redis } + end - def self.with - if @pool.nil? - POOL_MUTEX.synchronize do - @pool = ConnectionPool.new { ::Redis.new(url: url) } + def pool_size + if Sidekiq.server? + # the pool will be used in a multi-threaded context + Sidekiq.options[:concurrency] + 5 + else + # probably this is a Unicorn process, so single threaded + 5 end end - @pool.with { |redis| yield redis } + + def _raw_config + return @_raw_config if defined?(@_raw_config) + + begin + @_raw_config = File.read(CONFIG_FILE).freeze + rescue Errno::ENOENT + @_raw_config = false + end + + @_raw_config + end end - def self.redis_store_options - url = new.url - redis_config_hash = ::Redis::Store::Factory.extract_host_options_from_uri(url) - # Redis::Store does not handle Unix sockets well, so let's do it for them - redis_uri = URI.parse(url) + def initialize(rails_env = nil) + @rails_env = rails_env || ::Rails.env + end + + def params + redis_store_options + end + + def url + raw_config_hash[:url] + end + + private + + def redis_store_options + config = raw_config_hash + redis_url = config.delete(:url) + redis_uri = URI.parse(redis_url) + if redis_uri.scheme == 'unix' - redis_config_hash[:path] = redis_uri.path + # Redis::Store does not handle Unix sockets well, so let's do it for them + config[:path] = redis_uri.path + config + else + redis_hash = ::Redis::Store::Factory.extract_host_options_from_uri(redis_url) + # order is important here, sentinels must be after the connection keys. + # {url: ..., port: ..., sentinels: [...]} + redis_hash.merge(config) end - redis_config_hash end - def initialize(rails_env=nil) - rails_env ||= Rails.env - config_file = File.expand_path('../../../config/resque.yml', __FILE__) + def raw_config_hash + config_data = fetch_config - @url = "redis://localhost:6379" - if File.exist?(config_file) - @url = YAML.load_file(config_file)[rails_env] + if config_data + config_data.is_a?(String) ? { url: config_data } : config_data.deep_symbolize_keys + else + { url: DEFAULT_REDIS_URL } end end + + def fetch_config + self.class._raw_config ? YAML.load(self.class._raw_config)[@rails_env] : false + end end end diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index ffad5e17c78..0d30e1bb92e 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -2,7 +2,7 @@ module Gitlab module Regex extend self - NAMESPACE_REGEX_STR = '(?:[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*[a-zA-Z0-9_\-]|[a-zA-Z0-9_])'.freeze + NAMESPACE_REGEX_STR = '(?:[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*[a-zA-Z0-9_\-]|[a-zA-Z0-9_])(?<!\.git|\.atom)'.freeze def namespace_regex @namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze @@ -10,7 +10,7 @@ module Gitlab def namespace_regex_message "can contain only letters, digits, '_', '-' and '.'. " \ - "Cannot start with '-' or end in '.'." \ + "Cannot start with '-' or end in '.', '.git' or '.atom'." \ end def namespace_name_regex @@ -44,7 +44,7 @@ module Gitlab end def file_name_regex_message - "can contain only letters, digits, '_', '-', '@' and '.'. " + "can contain only letters, digits, '_', '-', '@' and '.'." end def file_path_regex @@ -52,7 +52,7 @@ module Gitlab end def file_path_regex_message - "can contain only letters, digits, '_', '-', '@' and '.'. Separate directories with a '/'. " + "can contain only letters, digits, '_', '-', '@' and '.'. Separate directories with a '/'." end def directory_traversal_regex @@ -60,7 +60,7 @@ module Gitlab end def directory_traversal_regex_message - "cannot include directory traversal. " + "cannot include directory traversal." end def archive_formats_regex @@ -96,11 +96,11 @@ module Gitlab end def environment_name_regex - @environment_name_regex ||= /\A[a-zA-Z0-9_-]+\z/.freeze + @environment_name_regex ||= /\A[a-zA-Z0-9_\\\/\${}. -]+\z/.freeze end def environment_name_regex_message - "can contain only letters, digits, '-' and '_'." + "can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.' and spaces" end end end diff --git a/lib/gitlab/request_profiler/middleware.rb b/lib/gitlab/request_profiler/middleware.rb index 4e787dc0656..786e1d49f5e 100644 --- a/lib/gitlab/request_profiler/middleware.rb +++ b/lib/gitlab/request_profiler/middleware.rb @@ -1,5 +1,5 @@ require 'ruby-prof' -require 'gitlab/request_profiler' +require_dependency 'gitlab/request_profiler' module Gitlab module RequestProfiler diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index f8ab2b1f09e..2690938fe82 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -27,11 +27,6 @@ module Gitlab end end - def total_count - @total_count ||= projects_count + issues_count + merge_requests_count + - milestones_count - end - def projects_count @projects_count ||= projects.count end @@ -48,10 +43,6 @@ module Gitlab @milestones_count ||= milestones.count end - def empty? - total_count.zero? - end - private def projects diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb new file mode 100644 index 00000000000..117fc508135 --- /dev/null +++ b/lib/gitlab/sentry.rb @@ -0,0 +1,27 @@ +module Gitlab + module Sentry + def self.enabled? + Rails.env.production? && current_application_settings.sentry_enabled? + end + + def self.context(current_user = nil) + return unless self.enabled? + + if current_user + Raven.user_context( + id: current_user.id, + email: current_user.email, + username: current_user.username, + ) + end + end + + def self.program_context + if Sidekiq.server? + 'sidekiq' + else + 'rails' + end + end + end +end diff --git a/lib/gitlab/sidekiq_middleware/arguments_logger.rb b/lib/gitlab/sidekiq_middleware/arguments_logger.rb index 7813091ec7b..82a59a7a87e 100644 --- a/lib/gitlab/sidekiq_middleware/arguments_logger.rb +++ b/lib/gitlab/sidekiq_middleware/arguments_logger.rb @@ -2,7 +2,7 @@ module Gitlab module SidekiqMiddleware class ArgumentsLogger def call(worker, job, queue) - Sidekiq.logger.info "arguments: #{job['args']}" + Sidekiq.logger.info "arguments: #{JSON.dump(job['args'])}" yield end end diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb new file mode 100644 index 00000000000..60d35be2599 --- /dev/null +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -0,0 +1,57 @@ +module Gitlab + module SlashCommands + class CommandDefinition + attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block + + def initialize(name, attributes = {}) + @name = name + + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @params = attributes[:params] || [] + @condition_block = attributes[:condition_block] + @action_block = attributes[:action_block] + end + + def all_names + [name, *aliases] + end + + def noop? + action_block.nil? + end + + def available?(opts) + return true unless condition_block + + context = OpenStruct.new(opts) + context.instance_exec(&condition_block) + end + + def execute(context, opts, arg) + return if noop? || !available?(opts) + + if arg.present? + context.instance_exec(arg, &action_block) + elsif action_block.arity == 0 + context.instance_exec(&action_block) + end + end + + def to_h(opts) + desc = description + if desc.respond_to?(:call) + context = OpenStruct.new(opts) + desc = context.instance_exec(&desc) rescue '' + end + + { + name: name, + aliases: aliases, + description: desc, + params: params + } + end + end + end +end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb new file mode 100644 index 00000000000..50b0937d267 --- /dev/null +++ b/lib/gitlab/slash_commands/dsl.rb @@ -0,0 +1,98 @@ +module Gitlab + module SlashCommands + module Dsl + extend ActiveSupport::Concern + + included do + cattr_accessor :command_definitions, instance_accessor: false do + [] + end + + cattr_accessor :command_definitions_by_name, instance_accessor: false do + {} + end + end + + class_methods do + # Allows to give a description to the next slash command. + # This description is shown in the autocomplete menu. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # desc do + # "This is a dynamic description for #{noteable.to_ability_name}" + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def desc(text = '', &block) + @description = block_given? ? block : text + end + + # Allows to define params for the next slash command. + # These params are shown in the autocomplete menu. + # + # Example: + # + # params "~label ~label2" + # command :command_key do |arguments| + # # Awesome code block + # end + def params(*params) + @params = params + end + + # Allows to define conditions that must be met in order for the command + # to be returned by `.command_names` & `.command_definitions`. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # condition do + # project.public? + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def condition(&block) + @condition_block = block + end + + # Registers a new command which is recognizeable from body of email or + # comment. + # It accepts aliases and takes a block. + # + # Example: + # + # command :my_command, :alias_for_my_command do |arguments| + # # Awesome code block + # end + def command(*command_names, &block) + name, *aliases = command_names + + definition = CommandDefinition.new( + name, + aliases: aliases, + description: @description, + params: @params, + condition_block: @condition_block, + action_block: block + ) + + self.command_definitions << definition + + definition.all_names.each do |name| + self.command_definitions_by_name[name] = definition + end + + @description = nil + @params = nil + @condition_block = nil + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb new file mode 100644 index 00000000000..a672e5e4855 --- /dev/null +++ b/lib/gitlab/slash_commands/extractor.rb @@ -0,0 +1,122 @@ +module Gitlab + module SlashCommands + # This class takes an array of commands that should be extracted from a + # given text. + # + # ``` + # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) + # ``` + class Extractor + attr_reader :command_definitions + + def initialize(command_definitions) + @command_definitions = command_definitions + end + + # Extracts commands from content and return an array of commands. + # The array looks like the following: + # [ + # ['command1'], + # ['command3', 'arg1 arg2'], + # ] + # The command and the arguments are stripped. + # The original command text is removed from the given `content`. + # + # Usage: + # ``` + # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) + # msg = %(hello\n/labels ~foo ~"bar baz"\nworld) + # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] + # msg #=> "hello\nworld" + # ``` + def extract_commands(content, opts = {}) + return [content, []] unless content + + content = content.dup + + commands = [] + + content.delete!("\r") + content.gsub!(commands_regex(opts)) do + if $~[:cmd] + commands << [$~[:cmd], $~[:arg]].reject(&:blank?) + '' + else + $~[0] + end + end + + [content.strip, commands] + end + + private + + # Builds a regular expression to match known commands. + # First match group captures the command name and + # second match group captures its arguments. + # + # It looks something like: + # + # /^\/(?<cmd>close|reopen|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/ + def commands_regex(opts) + names = command_names(opts).map(&:to_s) + + @commands_regex ||= %r{ + (?<code> + # Code blocks: + # ``` + # Anything, including `/cmd arg` which are ignored by this filter + # ``` + + ^``` + .+? + \n```$ + ) + | + (?<html> + # HTML block: + # <tag> + # Anything, including `/cmd arg` which are ignored by this filter + # </tag> + + ^<[^>]+?>\n + .+? + \n<\/[^>]+?>$ + ) + | + (?<html> + # Quote block: + # >>> + # Anything, including `/cmd arg` which are ignored by this filter + # >>> + + ^>>> + .+? + \n>>>$ + ) + | + (?: + # Command not in a blockquote, blockcode, or HTML tag: + # /close + + ^\/ + (?<cmd>#{Regexp.union(names)}) + (?: + [ ] + (?<arg>[^\/\n]*) + )? + (?:\n|$) + ) + }mx + end + + def command_names(opts) + command_definitions.flat_map do |command| + next if command.noop? + + command.all_names + end.compact + end + end + end +end diff --git a/lib/gitlab/snippet_search_results.rb b/lib/gitlab/snippet_search_results.rb index e0e74ff8359..9e01f02029c 100644 --- a/lib/gitlab/snippet_search_results.rb +++ b/lib/gitlab/snippet_search_results.rb @@ -20,10 +20,6 @@ module Gitlab end end - def total_count - @total_count ||= snippet_titles_count + snippet_blobs_count - end - def snippet_titles_count @snippet_titles_count ||= snippet_titles.count end diff --git a/lib/gitlab/template/base_template.rb b/lib/gitlab/template/base_template.rb index 760ff3e614a..7ebec8e2cff 100644 --- a/lib/gitlab/template/base_template.rb +++ b/lib/gitlab/template/base_template.rb @@ -1,8 +1,9 @@ module Gitlab module Template class BaseTemplate - def initialize(path) + def initialize(path, project = nil) @path = path + @finder = self.class.finder(project) end def name @@ -10,23 +11,32 @@ module Gitlab end def content - File.read(@path) + @finder.read(@path) + end + + def to_json + { name: name, content: content } end class << self - def all - self.categories.keys.flat_map { |cat| by_category(cat) } + def all(project = nil) + if categories.any? + categories.keys.flat_map { |cat| by_category(cat, project) } + else + by_category("", project) + end end - def find(key) - file_name = "#{key}#{self.extension}" - - directory = select_directory(file_name) - directory ? new(File.join(category_directory(directory), file_name)) : nil + def find(key, project = nil) + path = self.finder(project).find(key) + path.present? ? new(path, project) : nil end + # Set categories as sub directories + # Example: { "category_name_1" => "directory_path_1", "category_name_2" => "directory_name_2" } + # Default is no category with all files in base dir of each class def categories - raise NotImplementedError + {} end def extension @@ -37,29 +47,40 @@ module Gitlab raise NotImplementedError end - def by_category(category) - templates_for_directory(category_directory(category)) + # Defines which strategy will be used to get templates files + # RepoTemplateFinder - Finds templates on project repository, templates are filtered perproject + # GlobalTemplateFinder - Finds templates on gitlab installation source, templates can be used in all projects + def finder(project = nil) + raise NotImplementedError end - def category_directory(category) - File.join(base_dir, categories[category]) + def by_category(category, project = nil) + directory = category_directory(category) + files = finder(project).list_files_for(directory) + + files.map { |f| new(f, project) } end - private + def category_directory(category) + return base_dir unless category.present? - def select_directory(file_name) - categories.keys.find do |category| - File.exist?(File.join(category_directory(category), file_name)) - end + File.join(base_dir, categories[category]) end - def templates_for_directory(dir) - dir << '/' unless dir.end_with?('/') - Dir.glob(File.join(dir, "*#{self.extension}")).select { |f| f =~ filter_regex }.map { |f| new(f) } - end + # If template is organized by category it returns { category_name: [{ name: template_name }, { name: template2_name }] } + # If no category is present returns [{ name: template_name }, { name: template2_name}] + def dropdown_names(project = nil) + return [] if project && !project.repository.exists? - def filter_regex - @filter_reges ||= /#{Regexp.escape(extension)}\z/ + if categories.any? + categories.keys.map do |category| + files = self.by_category(category, project) + [category, files.map { |t| { name: t.name } }] + end.to_h + else + files = self.all(project) + files.map { |t| { name: t.name } } + end end end end diff --git a/lib/gitlab/template/finders/base_template_finder.rb b/lib/gitlab/template/finders/base_template_finder.rb new file mode 100644 index 00000000000..473b05257c6 --- /dev/null +++ b/lib/gitlab/template/finders/base_template_finder.rb @@ -0,0 +1,35 @@ +module Gitlab + module Template + module Finders + class BaseTemplateFinder + def initialize(base_dir) + @base_dir = base_dir + end + + def list_files_for + raise NotImplementedError + end + + def read + raise NotImplementedError + end + + def find + raise NotImplementedError + end + + def category_directory(category) + return @base_dir unless category.present? + + @base_dir + @categories[category] + end + + class << self + def filter_regex(extension) + /#{Regexp.escape(extension)}\z/ + end + end + end + end + end +end diff --git a/lib/gitlab/template/finders/global_template_finder.rb b/lib/gitlab/template/finders/global_template_finder.rb new file mode 100644 index 00000000000..831da45191f --- /dev/null +++ b/lib/gitlab/template/finders/global_template_finder.rb @@ -0,0 +1,38 @@ +# Searches and reads file present on Gitlab installation directory +module Gitlab + module Template + module Finders + class GlobalTemplateFinder < BaseTemplateFinder + def initialize(base_dir, extension, categories = {}) + @categories = categories + @extension = extension + super(base_dir) + end + + def read(path) + File.read(path) + end + + def find(key) + file_name = "#{key}#{@extension}" + + directory = select_directory(file_name) + directory ? File.join(category_directory(directory), file_name) : nil + end + + def list_files_for(dir) + dir << '/' unless dir.end_with?('/') + Dir.glob(File.join(dir, "*#{@extension}")).select { |f| f =~ self.class.filter_regex(@extension) } + end + + private + + def select_directory(file_name) + @categories.keys.find do |category| + File.exist?(File.join(category_directory(category), file_name)) + end + end + end + end + end +end diff --git a/lib/gitlab/template/finders/repo_template_finder.rb b/lib/gitlab/template/finders/repo_template_finder.rb new file mode 100644 index 00000000000..22c39436cb2 --- /dev/null +++ b/lib/gitlab/template/finders/repo_template_finder.rb @@ -0,0 +1,59 @@ +# Searches and reads files present on each Gitlab project repository +module Gitlab + module Template + module Finders + class RepoTemplateFinder < BaseTemplateFinder + # Raised when file is not found + class FileNotFoundError < StandardError; end + + def initialize(project, base_dir, extension, categories = {}) + @categories = categories + @extension = extension + @repository = project.repository + @commit = @repository.head_commit if @repository.exists? + + super(base_dir) + end + + def read(path) + blob = @repository.blob_at(@commit.id, path) if @commit + raise FileNotFoundError if blob.nil? + blob.data + end + + def find(key) + file_name = "#{key}#{@extension}" + directory = select_directory(file_name) + raise FileNotFoundError if directory.nil? + + category_directory(directory) + file_name + end + + def list_files_for(dir) + return [] unless @commit + + dir << '/' unless dir.end_with?('/') + + entries = @repository.tree(:head, dir).entries + + names = entries.map(&:name) + names.select { |f| f =~ self.class.filter_regex(@extension) } + end + + private + + def select_directory(file_name) + return [] unless @commit + + # Insert root as directory + directories = ["", @categories.keys] + + directories.find do |category| + path = category_directory(category) + file_name + @repository.blob_at(@commit.id, path) + end + end + end + end + end +end diff --git a/lib/gitlab/template/gitignore.rb b/lib/gitlab/template/gitignore_template.rb index 964fbfd4de3..8d2a9d2305c 100644 --- a/lib/gitlab/template/gitignore.rb +++ b/lib/gitlab/template/gitignore_template.rb @@ -1,6 +1,6 @@ module Gitlab module Template - class Gitignore < BaseTemplate + class GitignoreTemplate < BaseTemplate class << self def extension '.gitignore' @@ -16,6 +16,10 @@ module Gitlab def base_dir Rails.root.join('vendor/gitignore') end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end end end end diff --git a/lib/gitlab/template/gitlab_ci_yml.rb b/lib/gitlab/template/gitlab_ci_yml_template.rb index 7f480fe33c0..8d1a1ed54c9 100644 --- a/lib/gitlab/template/gitlab_ci_yml.rb +++ b/lib/gitlab/template/gitlab_ci_yml_template.rb @@ -1,6 +1,6 @@ module Gitlab module Template - class GitlabCiYml < BaseTemplate + class GitlabCiYmlTemplate < BaseTemplate def content explanation = "# This file is a template, and might need editing before it works on your project." [explanation, super].join("\n") @@ -21,6 +21,10 @@ module Gitlab def base_dir Rails.root.join('vendor/gitlab-ci-yml') end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end end end end diff --git a/lib/gitlab/template/issue_template.rb b/lib/gitlab/template/issue_template.rb new file mode 100644 index 00000000000..c6fa8d3eafc --- /dev/null +++ b/lib/gitlab/template/issue_template.rb @@ -0,0 +1,19 @@ +module Gitlab + module Template + class IssueTemplate < BaseTemplate + class << self + def extension + '.md' + end + + def base_dir + '.gitlab/issue_templates/' + end + + def finder(project) + Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/lib/gitlab/template/merge_request_template.rb b/lib/gitlab/template/merge_request_template.rb new file mode 100644 index 00000000000..f826c02f3b5 --- /dev/null +++ b/lib/gitlab/template/merge_request_template.rb @@ -0,0 +1,19 @@ +module Gitlab + module Template + class MergeRequestTemplate < BaseTemplate + class << self + def extension + '.md' + end + + def base_dir + '.gitlab/merge_request_templates/' + end + + def finder(project) + Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index fe65c246101..99d0c28e749 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -22,6 +22,8 @@ module Gitlab note_url when WikiPage wiki_page_url + when ProjectSnippet + project_snippet_url(object) else raise NotImplementedError.new("No URL builder defined for #{object.class}") end diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index 3a69027368f..9858d2e7d83 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -30,7 +30,9 @@ module Gitlab return false unless user if project.protected_branch?(ref) - access_levels = project.protected_branches.matching(ref).map(&:push_access_level) + return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user) + + access_levels = project.protected_branches.matching(ref).map(&:push_access_levels).flatten access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) @@ -41,7 +43,7 @@ module Gitlab return false unless user if project.protected_branch?(ref) - access_levels = project.protected_branches.matching(ref).map(&:merge_access_level) + access_levels = project.protected_branches.matching(ref).map(&:merge_access_levels).flatten access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index d13fe0ef8a9..e59ead5d76c 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -7,7 +7,7 @@ module Gitlab # @param cmd [Array<String>] # @return [Boolean] def system_silent(cmd) - Popen::popen(cmd).last.zero? + Popen.popen(cmd).last.zero? end def force_utf8(str) diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index c6826a09bd2..594439a5d4b 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -1,19 +1,38 @@ require 'base64' require 'json' +require 'securerandom' module Gitlab class Workhorse SEND_DATA_HEADER = 'Gitlab-Workhorse-Send-Data' VERSION_FILE = 'GITLAB_WORKHORSE_VERSION' + INTERNAL_API_CONTENT_TYPE = 'application/vnd.gitlab-workhorse+json' + INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request' + + # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32 + # bytes https://tools.ietf.org/html/rfc4868#section-2.6 + SECRET_LENGTH = 32 class << self def git_http_ok(repository, user) { - 'GL_ID' => Gitlab::GlId.gl_id(user), - 'RepoPath' => repository.path_to_repo, + GL_ID: Gitlab::GlId.gl_id(user), + RepoPath: repository.path_to_repo, + } + end + + def lfs_upload_ok(oid, size) + { + StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload", + LfsOid: oid, + LfsSize: size, } end + def artifact_upload_ok + { TempPath: ArtifactUploader.artifacts_upload_path } + end + def send_git_blob(repository, blob) params = { 'RepoPath' => repository.path_to_repo, @@ -41,7 +60,7 @@ module Gitlab def send_git_diff(repository, diff_refs) params = { 'RepoPath' => repository.path_to_repo, - 'ShaFrom' => diff_refs.start_sha, + 'ShaFrom' => diff_refs.base_sha, 'ShaTo' => diff_refs.head_sha } @@ -54,7 +73,7 @@ module Gitlab def send_git_patch(repository, diff_refs) params = { 'RepoPath' => repository.path_to_repo, - 'ShaFrom' => diff_refs.start_sha, + 'ShaFrom' => diff_refs.base_sha, 'ShaTo' => diff_refs.head_sha } @@ -81,6 +100,35 @@ module Gitlab path.readable? ? path.read.chomp : 'unknown' end + def secret + @secret ||= begin + bytes = Base64.strict_decode64(File.read(secret_path).chomp) + raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH + bytes + end + end + + def write_secret + bytes = SecureRandom.random_bytes(SECRET_LENGTH) + File.open(secret_path, 'w:BINARY', 0600) do |f| + f.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op. + f.write(Base64.strict_encode64(bytes)) + end + end + + def verify_api_request!(request_headers) + JWT.decode( + request_headers[INTERNAL_API_REQUEST_HEADER], + secret, + true, + { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' }, + ) + end + + def secret_path + Rails.root.join('.gitlab_workhorse_secret') + end + protected def encode(hash) diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab index 4a4892a2e07..d521de28e8a 100644 --- a/lib/support/nginx/gitlab +++ b/lib/support/nginx/gitlab @@ -49,12 +49,7 @@ server { proxy_http_version 1.1; - ## By overwriting Host and clearing X-Forwarded-Host we ensure that - ## internal HTTP redirects generated by GitLab always send users to - ## YOUR_SERVER_FQDN. - proxy_set_header Host YOUR_SERVER_FQDN; - proxy_set_header X-Forwarded-Host ""; - + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl index 0b93d7f292f..bf014b56cf6 100644 --- a/lib/support/nginx/gitlab-ssl +++ b/lib/support/nginx/gitlab-ssl @@ -93,12 +93,7 @@ server { proxy_http_version 1.1; - ## By overwriting Host and clearing X-Forwarded-Host we ensure that - ## internal HTTP redirects generated by GitLab always send users to - ## YOUR_SERVER_FQDN. - proxy_set_header Host YOUR_SERVER_FQDN; - proxy_set_header X-Forwarded-Host ""; - + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Ssl on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/lib/tasks/cache.rake b/lib/tasks/cache.rake index 2214f855200..a95a3455a4a 100644 --- a/lib/tasks/cache.rake +++ b/lib/tasks/cache.rake @@ -1,22 +1,33 @@ namespace :cache do - CLEAR_BATCH_SIZE = 1000 # There seems to be no speedup when pushing beyond 1,000 - REDIS_SCAN_START_STOP = '0' # Magic value, see http://redis.io/commands/scan + namespace :clear do + REDIS_CLEAR_BATCH_SIZE = 1000 # There seems to be no speedup when pushing beyond 1,000 + REDIS_SCAN_START_STOP = '0' # Magic value, see http://redis.io/commands/scan - desc "GitLab | Clear redis cache" - task :clear => :environment do - Gitlab::Redis.with do |redis| - cursor = REDIS_SCAN_START_STOP - loop do - cursor, keys = redis.scan( - cursor, - match: "#{Gitlab::Redis::CACHE_NAMESPACE}*", - count: CLEAR_BATCH_SIZE - ) - - redis.del(*keys) if keys.any? - - break if cursor == REDIS_SCAN_START_STOP + desc "GitLab | Clear redis cache" + task redis: :environment do + Gitlab::Redis.with do |redis| + cursor = REDIS_SCAN_START_STOP + loop do + cursor, keys = redis.scan( + cursor, + match: "#{Gitlab::Redis::CACHE_NAMESPACE}*", + count: REDIS_CLEAR_BATCH_SIZE + ) + + redis.del(*keys) if keys.any? + + break if cursor == REDIS_SCAN_START_STOP + end end end + + desc "GitLab | Clear database cache (in the background)" + task db: :environment do + ClearDatabaseCacheWorker.perform_async + end + + task all: [:db, :redis] end + + task clear: 'cache:clear:all' end diff --git a/lib/tasks/ce_to_ee_merge_check.rake b/lib/tasks/ce_to_ee_merge_check.rake new file mode 100644 index 00000000000..424e7883060 --- /dev/null +++ b/lib/tasks/ce_to_ee_merge_check.rake @@ -0,0 +1,4 @@ +desc 'Checks if the branch would apply cleanly to EE' +task ce_to_ee_merge_check: :environment do + Rake::Task['gitlab:dev:ce_to_ee_merge_check'].invoke +end diff --git a/lib/tasks/flog.rake b/lib/tasks/flog.rake deleted file mode 100644 index 3bfe999ae74..00000000000 --- a/lib/tasks/flog.rake +++ /dev/null @@ -1,25 +0,0 @@ -desc 'Code complexity analyze via flog' -task :flog do - output = %x(bundle exec flog -m app/ lib/gitlab) - exit_code = 0 - minimum_score = 70 - output = output.lines - - # Skip total complexity score - output.shift - - # Skip some trash info - output.shift - - output.each do |line| - score, method = line.split(" ") - score = score.to_i - - if score > minimum_score - exit_code = 1 - puts "High complexity in #{method}. Score: #{score}" - end - end - - exit exit_code -end diff --git a/lib/tasks/gitlab/bulk_add_permission.rake b/lib/tasks/gitlab/bulk_add_permission.rake index 5dbf7d61e06..83dd870fa31 100644 --- a/lib/tasks/gitlab/bulk_add_permission.rake +++ b/lib/tasks/gitlab/bulk_add_permission.rake @@ -4,13 +4,13 @@ namespace :gitlab do task all_users_to_all_projects: :environment do |t, args| user_ids = User.where(admin: false).pluck(:id) admin_ids = User.where(admin: true).pluck(:id) - projects_ids = Project.pluck(:id) + project_ids = Project.pluck(:id) - puts "Importing #{user_ids.size} users into #{projects_ids.size} projects" - ProjectMember.add_users_into_projects(projects_ids, user_ids, ProjectMember::DEVELOPER) + puts "Importing #{user_ids.size} users into #{project_ids.size} projects" + ProjectMember.add_users_to_projects(project_ids, user_ids, ProjectMember::DEVELOPER) - puts "Importing #{admin_ids.size} admins into #{projects_ids.size} projects" - ProjectMember.add_users_into_projects(projects_ids, admin_ids, ProjectMember::MASTER) + puts "Importing #{admin_ids.size} admins into #{project_ids.size} projects" + ProjectMember.add_users_to_projects(project_ids, admin_ids, ProjectMember::MASTER) end desc "GitLab | Add a specific user to all projects (as a developer)" @@ -18,7 +18,7 @@ namespace :gitlab do user = User.find_by(email: args.email) project_ids = Project.pluck(:id) puts "Importing #{user.email} users into #{project_ids.size} projects" - ProjectMember.add_users_into_projects(project_ids, Array.wrap(user.id), ProjectMember::DEVELOPER) + ProjectMember.add_users_to_projects(project_ids, Array.wrap(user.id), ProjectMember::DEVELOPER) end desc "GitLab | Add all users to all groups (admin users are added as owners)" diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index 60f4636e737..2ae48a970ce 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -46,7 +46,7 @@ namespace :gitlab do } correct_options = options.map do |name, value| - run(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value + run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value end if correct_options.all? @@ -64,7 +64,7 @@ namespace :gitlab do for_more_information( see_installation_guide_section "GitLab" ) - end + end end end @@ -73,7 +73,7 @@ namespace :gitlab do database_config_file = Rails.root.join("config", "database.yml") - if File.exists?(database_config_file) + if File.exist?(database_config_file) puts "yes".color(:green) else puts "no".color(:red) @@ -94,7 +94,7 @@ namespace :gitlab do gitlab_config_file = Rails.root.join("config", "gitlab.yml") - if File.exists?(gitlab_config_file) + if File.exist?(gitlab_config_file) puts "yes".color(:green) else puts "no".color(:red) @@ -113,7 +113,7 @@ namespace :gitlab do print "GitLab config outdated? ... " gitlab_config_file = Rails.root.join("config", "gitlab.yml") - unless File.exists?(gitlab_config_file) + unless File.exist?(gitlab_config_file) puts "can't check because of previous errors".color(:magenta) end @@ -144,7 +144,7 @@ namespace :gitlab do script_path = "/etc/init.d/gitlab" - if File.exists?(script_path) + if File.exist?(script_path) puts "yes".color(:green) else puts "no".color(:red) @@ -169,7 +169,7 @@ namespace :gitlab do recipe_path = Rails.root.join("lib/support/init.d/", "gitlab") script_path = "/etc/init.d/gitlab" - unless File.exists?(script_path) + unless File.exist?(script_path) puts "can't check because of previous errors".color(:magenta) return end @@ -316,7 +316,7 @@ namespace :gitlab do min_redis_version = "2.8.0" print "Redis version >= #{min_redis_version}? ... " - redis_version = run(%W(redis-cli --version)) + redis_version = run_command(%W(redis-cli --version)) redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/) if redis_version && (Gem::Version.new(redis_version[1]) > Gem::Version.new(min_redis_version)) @@ -361,7 +361,7 @@ namespace :gitlab do Gitlab.config.repositories.storages.each do |name, repo_base_path| print "#{name}... " - if File.exists?(repo_base_path) + if File.exist?(repo_base_path) puts "yes".color(:green) else puts "no".color(:red) @@ -385,7 +385,7 @@ namespace :gitlab do Gitlab.config.repositories.storages.each do |name, repo_base_path| print "#{name}... " - unless File.exists?(repo_base_path) + unless File.exist?(repo_base_path) puts "can't check because of previous errors".color(:magenta) return end @@ -408,7 +408,7 @@ namespace :gitlab do Gitlab.config.repositories.storages.each do |name, repo_base_path| print "#{name}... " - unless File.exists?(repo_base_path) + unless File.exist?(repo_base_path) puts "can't check because of previous errors".color(:magenta) return end @@ -438,7 +438,7 @@ namespace :gitlab do Gitlab.config.repositories.storages.each do |name, repo_base_path| print "#{name}... " - unless File.exists?(repo_base_path) + unless File.exist?(repo_base_path) puts "can't check because of previous errors".color(:magenta) return end @@ -671,7 +671,7 @@ namespace :gitlab do "Enable mail_room in the init.d configuration." ) for_more_information( - "doc/incoming_email/README.md" + "doc/administration/reply_by_email.md" ) fix_and_rerun end @@ -690,7 +690,7 @@ namespace :gitlab do "Enable mail_room in your Procfile." ) for_more_information( - "doc/incoming_email/README.md" + "doc/administration/reply_by_email.md" ) fix_and_rerun end @@ -747,7 +747,7 @@ namespace :gitlab do "Check that the information in config/gitlab.yml is correct" ) for_more_information( - "doc/incoming_email/README.md" + "doc/administration/reply_by_email.md" ) fix_and_rerun end @@ -893,7 +893,7 @@ namespace :gitlab do def check_ruby_version required_version = Gitlab::VersionInfo.new(2, 1, 0) - current_version = Gitlab::VersionInfo.parse(run(%W(ruby --version))) + current_version = Gitlab::VersionInfo.parse(run_command(%W(ruby --version))) print "Ruby version >= #{required_version} ? ... " @@ -910,7 +910,7 @@ namespace :gitlab do def check_git_version required_version = Gitlab::VersionInfo.new(2, 7, 3) - current_version = Gitlab::VersionInfo.parse(run(%W(#{Gitlab.config.git.bin_path} --version))) + current_version = Gitlab::VersionInfo.parse(run_command(%W(#{Gitlab.config.git.bin_path} --version))) puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\"" print "Git version >= #{required_version} ? ... " diff --git a/lib/tasks/gitlab/dev.rake b/lib/tasks/gitlab/dev.rake new file mode 100644 index 00000000000..47bdb2d32d2 --- /dev/null +++ b/lib/tasks/gitlab/dev.rake @@ -0,0 +1,107 @@ +namespace :gitlab do + namespace :dev do + desc 'Checks if the branch would apply cleanly to EE' + task ce_to_ee_merge_check: :environment do + return if defined?(Gitlab::License) + return unless ENV['CI'] + + ce_repo = ENV['CI_BUILD_REPO'] + ce_branch = ENV['CI_BUILD_REF_NAME'] + + ee_repo = 'https://gitlab.com/gitlab-org/gitlab-ee.git' + ee_branch = "#{ce_branch}-ee" + ee_dir = 'gitlab-ee-merge-check' + + puts "\n=> Cloning #{ee_repo} into #{ee_dir}\n" + `git clone #{ee_repo} #{ee_dir} --depth 1` + Dir.chdir(ee_dir) do + puts "\n => Fetching #{ce_repo}/#{ce_branch}\n" + `git fetch #{ce_repo} #{ce_branch} --depth 1` + + # Try to merge the current tested branch to EE/master... + puts "\n => Merging #{ce_repo}/#{ce_branch} into #{ee_repo}/master\n" + `git merge FETCH_HEAD` + + exit 0 if $?.success? + + # Check if the <branch>-ee branch exists... + puts "\n => Check if #{ee_repo}/#{ee_branch} exists\n" + `git rev-parse --verify #{ee_branch}` + + # The <branch>-ee doesn't exist + unless $?.success? + puts + puts <<-MSG.strip_heredoc + ================================================================= + The #{ce_branch} branch cannot be merged without conflicts to the + current EE/master, and no #{ee_branch} branch was detected in + the EE repository. + + Please create a #{ee_branch} branch that includes changes from + #{ce_branch} but also specific changes than can be applied cleanly + to EE/master. + + You can create this branch as follows: + + 1. In the EE repo: + $ git fetch origin + $ git fetch #{ce_repo} #{ce_branch} + $ git checkout -b #{ee_branch} FETCH_HEAD + $ git rebase origin/master + 2. At this point you will likely have conflicts, solve them, and + continue/finish the rebase. Note: You can squash the CE commits + before rebasing. + 3. You can squash all the original #{ce_branch} commits into a + single "Port of #{ce_branch} to EE". + 4. Push your branch to #{ee_repo}: + $ git push origin #{ee_branch} + =================================================================\n + MSG + + exit 1 + end + + # Try to merge the <branch>-ee branch to EE/master... + puts "\n => Merging #{ee_repo}/#{ee_branch} into #{ee_repo}/master\n" + `git merge #{ee_branch} master` + + # The <branch>-ee cannot be merged cleanly to EE/master... + unless $?.success? + puts + puts <<-MSG.strip_heredoc + ================================================================= + The #{ce_branch} branch cannot be merged without conflicts to + EE/master, and even though the #{ee_branch} branch exists in the EE + repository, it cannot be merged without conflicts to EE/master. + + Please update the #{ee_branch}, push it again to #{ee_repo}, and + retry this job. + =================================================================\n + MSG + + exit 2 + end + + puts "\n => Merging #{ce_repo}/#{ce_branch} into #{ee_repo}/master\n" + `git merge FETCH_HEAD` + exit 0 if $?.success? + + # The <branch>-ee can be merged cleanly to EE/master, but <branch> still + # cannot be merged cleanly to EE/master... + puts + puts <<-MSG.strip_heredoc + ================================================================= + The #{ce_branch} branch cannot be merged without conflicts to EE, and + even though the #{ee_branch} branch exists in the EE repository and + applies cleanly to EE/master, it doesn't prevent conflicts when + merging #{ce_branch} into EE. + + We may be in a complex situation here. + =================================================================\n + MSG + + exit 3 + end + end + end +end diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake index fe43d40e6d2..dffea8ed155 100644 --- a/lib/tasks/gitlab/info.rake +++ b/lib/tasks/gitlab/info.rake @@ -8,7 +8,7 @@ namespace :gitlab do # check Ruby version ruby_version = run_and_match(%W(ruby --version), /[\d\.p]+/).try(:to_s) # check Gem version - gem_version = run(%W(gem --version)) + gem_version = run_command(%W(gem --version)) # check Bundler version bunder_version = run_and_match(%W(bundle --version), /[\d\.]+/).try(:to_s) # check Bundler version @@ -17,7 +17,7 @@ namespace :gitlab do puts "" puts "System information".color(:yellow) puts "System:\t\t#{os_name || "unknown".color(:red)}" - puts "Current User:\t#{run(%W(whoami))}" + puts "Current User:\t#{run_command(%W(whoami))}" puts "Using RVM:\t#{rvm_version.present? ? "yes".color(:green) : "no"}" puts "RVM Version:\t#{rvm_version}" if rvm_version.present? puts "Ruby Version:\t#{ruby_version || "unknown".color(:red)}" diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index ba93945bd03..210899882b4 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -78,7 +78,7 @@ namespace :gitlab do f.puts "PATH=#{ENV['PATH']}" end - Gitlab::Shell.new.generate_and_link_secret_token + Gitlab::Shell.ensure_secret_token! end desc "GitLab | Setup gitlab-shell" @@ -90,7 +90,7 @@ namespace :gitlab do task build_missing_projects: :environment do Project.find_each(batch_size: 1000) do |project| path_to_repo = project.repository.path_to_repo - if File.exists?(path_to_repo) + if File.exist?(path_to_repo) print '-' else if Gitlab::Shell.new.add_repository(project.repository_storage_path, diff --git a/lib/tasks/gitlab/task_helpers.rake b/lib/tasks/gitlab/task_helpers.rake index ab96b1d3593..74be413423a 100644 --- a/lib/tasks/gitlab/task_helpers.rake +++ b/lib/tasks/gitlab/task_helpers.rake @@ -23,7 +23,7 @@ namespace :gitlab do # It will primarily use lsb_relase to determine the OS. # It has fallbacks to Debian, SuSE, OS X and systems running systemd. def os_name - os_name = run(%W(lsb_release -irs)) + os_name = run_command(%W(lsb_release -irs)) os_name ||= if File.readable?('/etc/system-release') File.read('/etc/system-release') end @@ -34,7 +34,7 @@ namespace :gitlab do os_name ||= if File.readable?('/etc/SuSE-release') File.read('/etc/SuSE-release') end - os_name ||= if os_x_version = run(%W(sw_vers -productVersion)) + os_name ||= if os_x_version = run_command(%W(sw_vers -productVersion)) "Mac OS X #{os_x_version}" end os_name ||= if File.readable?('/etc/os-release') @@ -62,10 +62,10 @@ namespace :gitlab do # Returns nil if nothing matched # Returns the MatchData if the pattern matched # - # see also #run + # see also #run_command # see also String#match def run_and_match(command, regexp) - run(command).try(:match, regexp) + run_command(command).try(:match, regexp) end # Runs the given command @@ -74,7 +74,7 @@ namespace :gitlab do # Returns the output of the command otherwise # # see also #run_and_match - def run(command) + def run_command(command) output, _ = Gitlab::Popen.popen(command) output rescue Errno::ENOENT @@ -82,7 +82,7 @@ namespace :gitlab do end def uid_for(user_name) - run(%W(id -u #{user_name})).chomp.to_i + run_command(%W(id -u #{user_name})).chomp.to_i end def gid_for(group_name) @@ -96,7 +96,7 @@ namespace :gitlab do def warn_user_is_not_gitlab unless @warned_user_not_gitlab gitlab_user = Gitlab.config.gitlab.user - current_user = run(%W(whoami)).chomp + current_user = run_command(%W(whoami)).chomp unless current_user == gitlab_user puts " Warning ".color(:black).background(:yellow) puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing." diff --git a/lib/tasks/gitlab/users.rake b/lib/tasks/gitlab/users.rake new file mode 100644 index 00000000000..3a16ace60bd --- /dev/null +++ b/lib/tasks/gitlab/users.rake @@ -0,0 +1,11 @@ +namespace :gitlab do + namespace :users do + desc "GitLab | Clear the authentication token for all users" + task clear_all_authentication_tokens: :environment do |t, args| + # Do small batched updates because these updates will be slow and locking + User.select(:id).find_in_batches(batch_size: 100) do |batch| + User.where(id: batch.map(&:id)).update_all(authentication_token: nil) + end + end + end +end diff --git a/lib/tasks/gitlab/web_hook.rake b/lib/tasks/gitlab/web_hook.rake index f467cc0ee29..49530e7a372 100644 --- a/lib/tasks/gitlab/web_hook.rake +++ b/lib/tasks/gitlab/web_hook.rake @@ -26,10 +26,10 @@ namespace :gitlab do namespace_path = ENV['NAMESPACE'] projects = find_projects(namespace_path) - projects_ids = projects.pluck(:id) + project_ids = projects.pluck(:id) puts "Removing webhooks with the url '#{web_hook_url}' ... " - count = WebHook.where(url: web_hook_url, project_id: projects_ids, type: 'ProjectHook').delete_all + count = WebHook.where(url: web_hook_url, project_id: project_ids, type: 'ProjectHook').delete_all puts "#{count} webhooks were removed." end diff --git a/lib/tasks/haml-lint.rake b/lib/tasks/haml-lint.rake new file mode 100644 index 00000000000..609dfaa48e3 --- /dev/null +++ b/lib/tasks/haml-lint.rake @@ -0,0 +1,5 @@ +unless Rails.env.production? + require 'haml_lint/rake_task' + + HamlLint::RakeTask.new +end diff --git a/lib/tasks/spinach.rake b/lib/tasks/spinach.rake index da255f5464b..8dbfa7751dc 100644 --- a/lib/tasks/spinach.rake +++ b/lib/tasks/spinach.rake @@ -34,21 +34,19 @@ task :spinach do run_spinach_tests(nil) end -def run_command(cmd) +def run_system_command(cmd) system({'RAILS_ENV' => 'test', 'force' => 'yes'}, *cmd) end def run_spinach_command(args) - run_command(%w(spinach -r rerun) + args) + run_system_command(%w(spinach -r rerun) + args) end def run_spinach_tests(tags) - #run_command(%w(rake gitlab:setup)) or raise('gitlab:setup failed!') - success = run_spinach_command(%W(--tags #{tags})) 3.times do |_| break if success - break unless File.exists?('tmp/spinach-rerun.txt') + break unless File.exist?('tmp/spinach-rerun.txt') tests = File.foreach('tmp/spinach-rerun.txt').map(&:chomp) puts '' diff --git a/public/deploy.html b/public/deploy.html index 142472b6c35..49ec4ac5ce1 100644 --- a/public/deploy.html +++ b/public/deploy.html @@ -2,6 +2,11 @@ <html> <head> <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> + <meta name="refresh" content="60"> + <meta name="retry-after" content="100"> + <meta name="robots" content="noindex, nofollow, noarchive, nostore"> + <meta name="cache-control" content="no-cache, no-store"> + <meta name="pragma" content="no-cache"> <title>Deploy in progress</title> <style> body { @@ -61,4 +66,4 @@ <p>Please contact your GitLab administrator if this problem persists.</p> </div> </body> -</html> +</html>
\ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt index 334f4c03533..7d69fad59d1 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -23,7 +23,7 @@ Disallow: /groups/*/edit Disallow: /users # Global snippets -Disallow: /s +Disallow: /s/ Disallow: /snippets/new Disallow: /snippets/*/edit Disallow: /snippets/*/raw diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh new file mode 100755 index 00000000000..fb4d8463981 --- /dev/null +++ b/scripts/lint-doc.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +cd "$(dirname "$0")/.." + +# Use long options (e.g. --header instead of -H) for curl examples in documentation. +grep --perl-regexp --recursive --color=auto 'curl (.+ )?-[^- ].*' doc/ +if [ $? == 0 ] +then + echo '✖ ERROR: Short options should not be used in documentation!' >&2 + exit 1 +fi + +# Ensure that the CHANGELOG does not contain duplicate versions +DUPLICATE_CHANGELOG_VERSIONS=$(grep --extended-regexp '^v [0-9.]+' CHANGELOG | sed 's| (unreleased)||' | sort | uniq -d) +if [ "${DUPLICATE_CHANGELOG_VERSIONS}" != "" ] +then + echo '✖ ERROR: Duplicate versions in CHANGELOG:' >&2 + echo "${DUPLICATE_CHANGELOG_VERSIONS}" >&2 + exit 1 +fi + +echo "✔ Linting passed" +exit 0 + diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index 7e71a030901..1eaafdce389 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -16,20 +16,6 @@ retry() { } if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then - mkdir -p vendor/apt - - # Install phantomjs package - pushd vendor/apt - if [ ! -e phantomjs_1.9.8-0jessie_amd64.deb ]; then - wget -q https://gitlab.com/axil/phantomjs-debian/raw/master/phantomjs_1.9.8-0jessie_amd64.deb - fi - dpkg -i phantomjs_1.9.8-0jessie_amd64.deb - popd - - # Try to install packages - retry 'apt-get update -yqqq; apt-get -o dir::cache::archives="vendor/apt" install -y -qq --force-yes \ - libicu-dev libkrb5-dev cmake nodejs postgresql-client mysql-client unzip' - cp config/database.yml.mysql config/database.yml sed -i 's/username:.*/username: root/g' config/database.yml sed -i 's/password:.*/password:/g' config/database.yml @@ -38,7 +24,7 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then cp config/resque.yml.example config/resque.yml sed -i 's/localhost/redis/g' config/resque.yml - export FLAGS=(--path vendor --retry 3) + export FLAGS=(--path vendor --retry 3 --quiet) else export PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin cp config/database.yml.mysql config/database.yml diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb index 6fad7e2b9e7..c5d3cd70acc 100644 --- a/spec/config/mail_room_spec.rb +++ b/spec/config/mail_room_spec.rb @@ -1,53 +1,48 @@ -require "spec_helper" +require 'spec_helper' -describe "mail_room.yml" do - let(:config_path) { "config/mail_room.yml" } +describe 'mail_room.yml' do + let(:config_path) { 'config/mail_room.yml' } let(:configuration) { YAML.load(ERB.new(File.read(config_path)).result) } - context "when incoming email is disabled" do + context 'when incoming email is disabled' do before do - ENV["MAIL_ROOM_GITLAB_CONFIG_FILE"] = Rails.root.join("spec/fixtures/mail_room_disabled.yml").to_s + ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = Rails.root.join('spec/fixtures/mail_room_disabled.yml').to_s + Gitlab::MailRoom.reset_config! end after do - ENV["MAIL_ROOM_GITLAB_CONFIG_FILE"] = nil + ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = nil end - it "contains no configuration" do + it 'contains no configuration' do expect(configuration[:mailboxes]).to be_nil end end - context "when incoming email is enabled" do + context 'when incoming email is enabled' do before do - ENV["MAIL_ROOM_GITLAB_CONFIG_FILE"] = Rails.root.join("spec/fixtures/mail_room_enabled.yml").to_s + ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = Rails.root.join('spec/fixtures/mail_room_enabled.yml').to_s + Gitlab::MailRoom.reset_config! end after do - ENV["MAIL_ROOM_GITLAB_CONFIG_FILE"] = nil + ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = nil end - it "contains the intended configuration" do + it 'contains the intended configuration' do expect(configuration[:mailboxes].length).to eq(1) mailbox = configuration[:mailboxes].first - expect(mailbox[:host]).to eq("imap.gmail.com") + expect(mailbox[:host]).to eq('imap.gmail.com') expect(mailbox[:port]).to eq(993) expect(mailbox[:ssl]).to eq(true) expect(mailbox[:start_tls]).to eq(false) - expect(mailbox[:email]).to eq("gitlab-incoming@gmail.com") - expect(mailbox[:password]).to eq("[REDACTED]") - expect(mailbox[:name]).to eq("inbox") - - redis_config_file = Rails.root.join('config', 'resque.yml') - - redis_url = - if File.exist?(redis_config_file) - YAML.load_file(redis_config_file)[Rails.env] - else - "redis://localhost:6379" - end + expect(mailbox[:email]).to eq('gitlab-incoming@gmail.com') + expect(mailbox[:password]).to eq('[REDACTED]') + expect(mailbox[:name]).to eq('inbox') + + redis_url = Gitlab::Redis.url expect(mailbox[:delivery_options][:redis_url]).to eq(redis_url) expect(mailbox[:arbitration_options][:redis_url]).to eq(redis_url) diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb new file mode 100644 index 00000000000..602de72d23f --- /dev/null +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Admin::GroupsController do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + let(:admin) { create(:admin) } + + before do + sign_in(admin) + end + + describe 'DELETE #destroy' do + it 'schedules a group destroy' do + Sidekiq::Testing.fake! do + expect { delete :destroy, id: project.group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + end + end + + it 'redirects to the admin group path' do + delete :destroy, id: project.group.path + + expect(response).to redirect_to(admin_groups_path) + end + end +end diff --git a/spec/controllers/admin/impersonations_controller_spec.rb b/spec/controllers/admin/impersonations_controller_spec.rb index d5f0b289b5b..8be662974a0 100644 --- a/spec/controllers/admin/impersonations_controller_spec.rb +++ b/spec/controllers/admin/impersonations_controller_spec.rb @@ -77,6 +77,8 @@ describe Admin::ImpersonationsController do context "when the impersonator is not blocked" do it "redirects to the impersonated user's page" do + expect(Gitlab::AppLogger).to receive(:info).with("User #{impersonator.username} has stopped impersonating #{user.username}").and_call_original + delete :destroy expect(response).to redirect_to(admin_user_path(user)) diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb index 520a4f6f9c5..585ca31389d 100644 --- a/spec/controllers/admin/spam_logs_controller_spec.rb +++ b/spec/controllers/admin/spam_logs_controller_spec.rb @@ -34,4 +34,16 @@ describe Admin::SpamLogsController do expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) end end + + describe '#mark_as_ham' do + before do + allow_any_instance_of(AkismetService).to receive(:submit_ham).and_return(true) + end + it 'submits the log as ham' do + post :mark_as_ham, id: first_spam.id + + expect(response).to have_http_status(302) + expect(SpamLog.find(first_spam.id).submitted_as_ham).to be_truthy + end + end end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index ab9aa65f7b9..33fe3c73822 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -39,7 +39,7 @@ describe Admin::UsersController do user.ldap_block end - it 'will not unblock user' do + it 'does not unblock user' do put :unblock, id: user.username user.reload expect(user.blocked?).to be_truthy diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 8bd210cbc3d..98e912f000c 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -5,7 +5,7 @@ describe ApplicationController do let(:user) { create(:user) } let(:controller) { ApplicationController.new } - it 'should redirect if the user is over their password expiry' do + it 'redirects if the user is over their password expiry' do user.password_expires_at = Time.new(2002) expect(user.ldap_user?).to be_falsey allow(controller).to receive(:current_user).and_return(user) @@ -14,7 +14,7 @@ describe ApplicationController do controller.send(:check_password_expiration) end - it 'should not redirect if the user is under their password expiry' do + it 'does not redirect if the user is under their password expiry' do user.password_expires_at = Time.now + 20010101 expect(user.ldap_user?).to be_falsey allow(controller).to receive(:current_user).and_return(user) @@ -22,7 +22,7 @@ describe ApplicationController do controller.send(:check_password_expiration) end - it 'should not redirect if the user is over their password expiry but they are an ldap user' do + it 'does not redirect if the user is over their password expiry but they are an ldap user' do user.password_expires_at = Time.new(2002) allow(user).to receive(:ldap_user?).and_return(true) allow(controller).to receive(:current_user).and_return(user) diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index ed0b7f9e240..a121cb2fc97 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -2,178 +2,312 @@ require 'spec_helper' describe AutocompleteController do let!(:project) { create(:project) } - let!(:user) { create(:user) } - let!(:user2) { create(:user) } - let!(:non_member) { create(:user) } + let!(:user) { create(:user) } - context 'project members' do - before do - sign_in(user) - project.team << [user, :master] - end + context 'users and members' do + let!(:user2) { create(:user) } + let!(:non_member) { create(:user) } - describe 'GET #users with project ID' do + context 'project members' do before do - get(:users, project_id: project.id) + sign_in(user) + project.team << [user, :master] end - let(:body) { JSON.parse(response.body) } + describe 'GET #users with project ID' do + before do + get(:users, project_id: project.id) + end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } - it { expect(body.map { |u| u["username"] }).to include(user.username) } + let(:body) { JSON.parse(response.body) } + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 1 } + it { expect(body.map { |u| u["username"] }).to include(user.username) } + end + + describe 'GET #users with unknown project' do + before do + get(:users, project_id: 'unknown') + end + + it { expect(response).to have_http_status(404) } + end end - describe 'GET #users with unknown project' do + context 'group members' do + let(:group) { create(:group) } + before do - get(:users, project_id: 'unknown') + sign_in(user) + group.add_owner(user) end - it { expect(response).to have_http_status(404) } - end - end + let(:body) { JSON.parse(response.body) } - context 'group members' do - let(:group) { create(:group) } + describe 'GET #users with group ID' do + before do + get(:users, group_id: group.id) + end - before do - sign_in(user) - group.add_owner(user) + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 1 } + it { expect(body.first["username"]).to eq user.username } + end + + describe 'GET #users with unknown group ID' do + before do + get(:users, group_id: 'unknown') + end + + it { expect(response).to have_http_status(404) } + end end - let(:body) { JSON.parse(response.body) } + context 'non-member login for public project' do + let!(:project) { create(:project, :public) } - describe 'GET #users with group ID' do before do - get(:users, group_id: group.id) + sign_in(non_member) + project.team << [user, :master] end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } - it { expect(body.first["username"]).to eq user.username } + let(:body) { JSON.parse(response.body) } + + describe 'GET #users with project ID' do + before do + get(:users, project_id: project.id, current_user: true) + end + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 2 } + it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) } + end end - describe 'GET #users with unknown group ID' do + context 'all users' do before do - get(:users, group_id: 'unknown') + sign_in(user) + get(:users) end - it { expect(response).to have_http_status(404) } + let(:body) { JSON.parse(response.body) } + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq User.count } end - end - context 'non-member login for public project' do - let!(:project) { create(:project, :public) } + context 'unauthenticated user' do + let(:public_project) { create(:project, :public) } + let(:body) { JSON.parse(response.body) } - before do - sign_in(non_member) - project.team << [user, :master] - end + describe 'GET #users with public project' do + before do + public_project.team << [user, :guest] + get(:users, project_id: public_project.id) + end + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 1 } + end - let(:body) { JSON.parse(response.body) } + describe 'GET #users with project' do + before do + get(:users, project_id: project.id) + end - describe 'GET #users with project ID' do - before do - get(:users, project_id: project.id, current_user: true) + it { expect(response).to have_http_status(404) } end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 2 } - it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) } - end - end + describe 'GET #users with unknown project' do + before do + get(:users, project_id: 'unknown') + end - context 'all users' do - before do - sign_in(user) - get(:users) - end + it { expect(response).to have_http_status(404) } + end - let(:body) { JSON.parse(response.body) } + describe 'GET #users with inaccessible group' do + before do + project.team << [user, :guest] + get(:users, group_id: user.namespace.id) + end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq User.count } - end + it { expect(response).to have_http_status(404) } + end + + describe 'GET #users with no project' do + before do + get(:users) + end - context 'unauthenticated user' do - let(:public_project) { create(:project, :public) } - let(:body) { JSON.parse(response.body) } + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq 0 } + end + end - describe 'GET #users with public project' do + context 'author of issuable included' do before do - public_project.team << [user, :guest] - get(:users, project_id: public_project.id) + sign_in(user) end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } + let(:body) { JSON.parse(response.body) } + + it 'includes the author' do + get(:users, author_id: non_member.id) + + expect(body.first["username"]).to eq non_member.username + end + + it 'rejects non existent user ids' do + get(:users, author_id: 99999) + + expect(body.collect { |u| u['id'] }).not_to include(99999) + end end - describe 'GET #users with project' do - before do - get(:users, project_id: project.id) + context 'skip_users parameter included' do + before { sign_in(user) } + + it 'skips the user IDs passed' do + get(:users, skip_users: [user, user2].map(&:id)) + + other_user_ids = [non_member, project.owner, project.creator].map(&:id) + response_user_ids = JSON.parse(response.body).map { |user| user['id'] } + + expect(response_user_ids).to contain_exactly(*other_user_ids) end + end + end + + context 'projects' do + let(:authorized_project) { create(:project) } + let(:authorized_search_project) { create(:project, name: 'rugged') } - it { expect(response).to have_http_status(404) } + before do + sign_in(user) + project.team << [user, :master] end - describe 'GET #users with unknown project' do + context 'authorized projects' do before do - get(:users, project_id: 'unknown') + authorized_project.team << [user, :master] end - it { expect(response).to have_http_status(404) } + describe 'GET #projects with project ID' do + before do + get(:projects, project_id: project.id) + end + + let(:body) { JSON.parse(response.body) } + + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 2 + + expect(body.first['id']).to eq 0 + expect(body.first['name_with_namespace']).to eq 'No project' + + expect(body.last['id']).to eq authorized_project.id + expect(body.last['name_with_namespace']).to eq authorized_project.name_with_namespace + end + end end - describe 'GET #users with inaccessible group' do + context 'authorized projects and search' do before do - project.team << [user, :guest] - get(:users, group_id: user.namespace.id) + authorized_project.team << [user, :master] + authorized_search_project.team << [user, :master] end - it { expect(response).to have_http_status(404) } + describe 'GET #projects with project ID and search' do + before do + get(:projects, project_id: project.id, search: 'rugged') + end + + let(:body) { JSON.parse(response.body) } + + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 2 + + expect(body.last['id']).to eq authorized_search_project.id + expect(body.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace + end + end end - describe 'GET #users with no project' do + context 'authorized projects apply limit' do before do - get(:users) + authorized_project2 = create(:project) + authorized_project3 = create(:project) + + authorized_project.team << [user, :master] + authorized_project2.team << [user, :master] + authorized_project3.team << [user, :master] + + stub_const 'MoveToProjectFinder::PAGE_SIZE', 2 end - it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 0 } - end - end + describe 'GET #projects with project ID' do + before do + get(:projects, project_id: project.id) + end - context 'author of issuable included' do - before do - sign_in(user) + let(:body) { JSON.parse(response.body) } + + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 3 # Of a total of 4 + end + end end - let(:body) { JSON.parse(response.body) } + context 'authorized projects with offset' do + before do + authorized_project2 = create(:project) + authorized_project3 = create(:project) - it 'includes the author' do - get(:users, author_id: non_member.id) + authorized_project.team << [user, :master] + authorized_project2.team << [user, :master] + authorized_project3.team << [user, :master] + end - expect(body.first["username"]).to eq non_member.username - end + describe 'GET #projects with project ID and offset_id' do + before do + get(:projects, project_id: project.id, offset_id: authorized_project.id) + end - it 'rejects non existent user ids' do - get(:users, author_id: 99999) + let(:body) { JSON.parse(response.body) } - expect(body.collect { |u| u['id'] }).not_to include(99999) + it do + expect(body.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there + expect(body.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either + end + end end - end - context 'skip_users parameter included' do - before { sign_in(user) } + context 'authorized projects without admin_issue ability' do + before(:each) do + authorized_project.team << [user, :guest] + + expect(user.can?(:admin_issue, authorized_project)).to eq(false) + end + + describe 'GET #projects with project ID' do + before do + get(:projects, project_id: project.id) + end - it 'skips the user IDs passed' do - get(:users, skip_users: [user, user2].map(&:id)) + let(:body) { JSON.parse(response.body) } - other_user_ids = [non_member, project.owner, project.creator].map(&:id) - response_user_ids = JSON.parse(response.body).map { |user| user['id'] } + it do + expect(body).to be_kind_of(Array) + expect(body.size).to eq 1 # 'No project' - expect(response_user_ids).to contain_exactly(*other_user_ids) + expect(body.first['id']).to eq 0 + end + end end end end diff --git a/spec/controllers/groups/avatars_controller_spec.rb b/spec/controllers/groups/avatars_controller_spec.rb index 91d639218e5..506aeee7d2a 100644 --- a/spec/controllers/groups/avatars_controller_spec.rb +++ b/spec/controllers/groups/avatars_controller_spec.rb @@ -9,7 +9,7 @@ describe Groups::AvatarsController do sign_in(user) end - it 'destroy should remove avatar from DB' do + it 'removes avatar from DB calling destroy' do delete :destroy, group_id: group.path @group = assigns(:group) expect(@group.avatar.present?).to be_falsey diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index c34475976c6..a0870891cf4 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -2,9 +2,10 @@ require 'spec_helper' describe Groups::GroupMembersController do let(:user) { create(:user) } - let(:group) { create(:group) } describe '#index' do + let(:group) { create(:group) } + before do group.add_owner(user) stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) @@ -86,10 +87,10 @@ describe Groups::GroupMembersController do context 'when member is not found' do before { sign_in(user) } - it 'returns 403' do + it 'returns 404' do delete :leave, group_id: group - expect(response).to have_http_status(403) + expect(response).to have_http_status(404) end end diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb index b0793cb1655..8c52f615b8b 100644 --- a/spec/controllers/groups/milestones_controller_spec.rb +++ b/spec/controllers/groups/milestones_controller_spec.rb @@ -15,7 +15,7 @@ describe Groups::MilestonesController do end describe "#create" do - it "should create group milestone with Chinese title" do + it "creates group milestone with Chinese title" do post :create, group_id: group.id, milestone: { project_ids: [project.id, project2.id], title: title } diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index cd98fecd0c7..a763e2c5ba8 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -75,4 +75,34 @@ describe GroupsController do end end end + + describe 'DELETE #destroy' do + context 'as another user' do + it 'returns 404' do + sign_in(create(:user)) + + delete :destroy, id: group.path + + expect(response.status).to eq(404) + end + end + + context 'as the group owner' do + before do + sign_in(user) + end + + it 'schedules a group destroy' do + Sidekiq::Testing.fake! do + expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + end + end + + it 'redirects to the root path' do + delete :destroy, id: group.path + + expect(response).to redirect_to(root_path) + end + end + end end diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 07bf8d2d1c3..1d3c9fbbe2f 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -146,21 +146,42 @@ describe Import::BitbucketController do end context "when a namespace with the Bitbucket user's username doesn't exist" do - it "creates the namespace" do - expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).and_return(double(execute: true)) + context "when current user can create namespaces" do + it "creates the namespace" do + expect(Gitlab::BitbucketImport::ProjectCreator). + to receive(:new).and_return(double(execute: true)) - post :create, format: :js + expect { post :create, format: :js }.to change(Namespace, :count).by(1) + end + + it "takes the new namespace" do + expect(Gitlab::BitbucketImport::ProjectCreator). + to receive(:new).with(bitbucket_repo, an_instance_of(Group), user, access_params). + and_return(double(execute: true)) - expect(Namespace.where(name: other_username).first).not_to be_nil + post :create, format: :js + end end - it "takes the new namespace" do - expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).with(bitbucket_repo, an_instance_of(Group), user, access_params). - and_return(double(execute: true)) + context "when current user can't create namespaces" do + before do + user.update_attribute(:can_create_group, false) + end - post :create, format: :js + it "doesn't create the namespace" do + expect(Gitlab::BitbucketImport::ProjectCreator). + to receive(:new).and_return(double(execute: true)) + + expect { post :create, format: :js }.not_to change(Namespace, :count) + end + + it "takes the current user's namespace" do + expect(Gitlab::BitbucketImport::ProjectCreator). + to receive(:new).with(bitbucket_repo, user.namespace, user, access_params). + and_return(double(execute: true)) + + post :create, format: :js + end end end end diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb index 51d59526854..4f96567192d 100644 --- a/spec/controllers/import/github_controller_spec.rb +++ b/spec/controllers/import/github_controller_spec.rb @@ -124,8 +124,8 @@ describe Import::GithubController do context "when the GitHub user and GitLab user's usernames match" do it "takes the current user's namespace" do expect(Gitlab::GithubImport::ProjectCreator). - to receive(:new).with(github_repo, user.namespace, user, access_params). - and_return(double(execute: true)) + to receive(:new).with(github_repo, github_repo.name, user.namespace, user, access_params). + and_return(double(execute: true)) post :create, format: :js end @@ -136,8 +136,8 @@ describe Import::GithubController do it "takes the current user's namespace" do expect(Gitlab::GithubImport::ProjectCreator). - to receive(:new).with(github_repo, user.namespace, user, access_params). - and_return(double(execute: true)) + to receive(:new).with(github_repo, github_repo.name, user.namespace, user, access_params). + and_return(double(execute: true)) post :create, format: :js end @@ -158,8 +158,8 @@ describe Import::GithubController do context "when the namespace is owned by the GitLab user" do it "takes the existing namespace" do expect(Gitlab::GithubImport::ProjectCreator). - to receive(:new).with(github_repo, existing_namespace, user, access_params). - and_return(double(execute: true)) + to receive(:new).with(github_repo, github_repo.name, existing_namespace, user, access_params). + and_return(double(execute: true)) post :create, format: :js end @@ -171,9 +171,10 @@ describe Import::GithubController do existing_namespace.save end - it "doesn't create a project" do + it "creates a project using user's namespace" do expect(Gitlab::GithubImport::ProjectCreator). - not_to receive(:new) + to receive(:new).with(github_repo, github_repo.name, user.namespace, user, access_params). + and_return(double(execute: true)) post :create, format: :js end @@ -181,21 +182,63 @@ describe Import::GithubController do end context "when a namespace with the GitHub user's username doesn't exist" do - it "creates the namespace" do - expect(Gitlab::GithubImport::ProjectCreator). - to receive(:new).and_return(double(execute: true)) + context "when current user can create namespaces" do + it "creates the namespace" do + expect(Gitlab::GithubImport::ProjectCreator). + to receive(:new).and_return(double(execute: true)) - post :create, format: :js + expect { post :create, target_namespace: github_repo.name, format: :js }.to change(Namespace, :count).by(1) + end + + it "takes the new namespace" do + expect(Gitlab::GithubImport::ProjectCreator). + to receive(:new).with(github_repo, github_repo.name, an_instance_of(Group), user, access_params). + and_return(double(execute: true)) + + post :create, target_namespace: github_repo.name, format: :js + end + end + + context "when current user can't create namespaces" do + before do + user.update_attribute(:can_create_group, false) + end + + it "doesn't create the namespace" do + expect(Gitlab::GithubImport::ProjectCreator). + to receive(:new).and_return(double(execute: true)) + + expect { post :create, format: :js }.not_to change(Namespace, :count) + end - expect(Namespace.where(name: other_username).first).not_to be_nil + it "takes the current user's namespace" do + expect(Gitlab::GithubImport::ProjectCreator). + to receive(:new).with(github_repo, github_repo.name, user.namespace, user, access_params). + and_return(double(execute: true)) + + post :create, format: :js + end end + end - it "takes the new namespace" do + context 'user has chosen a namespace and name for the project' do + let(:test_namespace) { create(:namespace, name: 'test_namespace', owner: user) } + let(:test_name) { 'test_name' } + + it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). - to receive(:new).with(github_repo, an_instance_of(Group), user, access_params). - and_return(double(execute: true)) + to receive(:new).with(github_repo, test_name, test_namespace, user, access_params). + and_return(double(execute: true)) - post :create, format: :js + post :create, { target_namespace: test_namespace.name, new_name: test_name, format: :js } + end + + it 'takes the selected name and default namespace' do + expect(Gitlab::GithubImport::ProjectCreator). + to receive(:new).with(github_repo, test_name, user.namespace, user, access_params). + and_return(double(execute: true)) + + post :create, { new_name: test_name, format: :js } end end end diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb index e8cf6aa7767..6f75ebb16c8 100644 --- a/spec/controllers/import/gitlab_controller_spec.rb +++ b/spec/controllers/import/gitlab_controller_spec.rb @@ -136,21 +136,42 @@ describe Import::GitlabController do end context "when a namespace with the GitLab.com user's username doesn't exist" do - it "creates the namespace" do - expect(Gitlab::GitlabImport::ProjectCreator). - to receive(:new).and_return(double(execute: true)) + context "when current user can create namespaces" do + it "creates the namespace" do + expect(Gitlab::GitlabImport::ProjectCreator). + to receive(:new).and_return(double(execute: true)) - post :create, format: :js + expect { post :create, format: :js }.to change(Namespace, :count).by(1) + end + + it "takes the new namespace" do + expect(Gitlab::GitlabImport::ProjectCreator). + to receive(:new).with(gitlab_repo, an_instance_of(Group), user, access_params). + and_return(double(execute: true)) - expect(Namespace.where(name: other_username).first).not_to be_nil + post :create, format: :js + end end - it "takes the new namespace" do - expect(Gitlab::GitlabImport::ProjectCreator). - to receive(:new).with(gitlab_repo, an_instance_of(Group), user, access_params). - and_return(double(execute: true)) + context "when current user can't create namespaces" do + before do + user.update_attribute(:can_create_group, false) + end - post :create, format: :js + it "doesn't create the namespace" do + expect(Gitlab::GitlabImport::ProjectCreator). + to receive(:new).and_return(double(execute: true)) + + expect { post :create, format: :js }.not_to change(Namespace, :count) + end + + it "takes the current user's namespace" do + expect(Gitlab::GitlabImport::ProjectCreator). + to receive(:new).with(gitlab_repo, user.namespace, user, access_params). + and_return(double(execute: true)) + + post :create, format: :js + end end end end diff --git a/spec/controllers/import/gitorious_controller_spec.rb b/spec/controllers/import/gitorious_controller_spec.rb deleted file mode 100644 index 4ae2b78e11c..00000000000 --- a/spec/controllers/import/gitorious_controller_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -require 'spec_helper' - -describe Import::GitoriousController do - include ImportSpecHelper - - let(:user) { create(:user) } - - before do - sign_in(user) - end - - describe "GET new" do - it "redirects to import endpoint on gitorious.org" do - get :new - - expect(controller).to redirect_to("https://gitorious.org/gitlab-import?callback_url=http://test.host/import/gitorious/callback") - end - end - - describe "GET callback" do - it "stores repo list in session" do - get :callback, repos: 'foo/bar,baz/qux' - - expect(session[:gitorious_repos]).to eq('foo/bar,baz/qux') - end - end - - describe "GET status" do - before do - @repo = OpenStruct.new(full_name: 'asd/vim') - end - - it "assigns variables" do - @project = create(:project, import_type: 'gitorious', creator_id: user.id) - stub_client(repos: [@repo]) - - get :status - - expect(assigns(:already_added_projects)).to eq([@project]) - expect(assigns(:repos)).to eq([@repo]) - end - - it "does not show already added project" do - @project = create(:project, import_type: 'gitorious', creator_id: user.id, import_source: 'asd/vim') - stub_client(repos: [@repo]) - - get :status - - expect(assigns(:already_added_projects)).to eq([@project]) - expect(assigns(:repos)).to eq([]) - end - end - - describe "POST create" do - before do - @repo = Gitlab::GitoriousImport::Repository.new('asd/vim') - end - - it "takes already existing namespace" do - namespace = create(:namespace, name: "asd", owner: user) - expect(Gitlab::GitoriousImport::ProjectCreator). - to receive(:new).with(@repo, namespace, user). - and_return(double(execute: true)) - stub_client(repo: @repo) - - post :create, format: :js - end - end -end diff --git a/spec/controllers/namespaces_controller_spec.rb b/spec/controllers/namespaces_controller_spec.rb deleted file mode 100644 index 2b334ed1172..00000000000 --- a/spec/controllers/namespaces_controller_spec.rb +++ /dev/null @@ -1,118 +0,0 @@ -require 'spec_helper' - -describe NamespacesController do - let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } - - describe "GET show" do - context "when the namespace belongs to a user" do - let!(:other_user) { create(:user) } - - it "redirects to the user's page" do - get :show, id: other_user.username - - expect(response).to redirect_to(user_path(other_user)) - end - end - - context "when the namespace belongs to a group" do - let!(:group) { create(:group) } - - context "when the group is public" do - context "when not signed in" do - it "redirects to the group's page" do - get :show, id: group.path - - expect(response).to redirect_to(group_path(group)) - end - end - - context "when signed in" do - before do - sign_in(user) - end - - it "redirects to the group's page" do - get :show, id: group.path - - expect(response).to redirect_to(group_path(group)) - end - end - end - - context "when the group is private" do - before do - group.update_attribute(:visibility_level, Group::PRIVATE) - end - - context "when not signed in" do - it "redirects to the sign in page" do - get :show, id: group.path - expect(response).to redirect_to(new_user_session_path) - end - end - - context "when signed in" do - before do - sign_in(user) - end - - context "when the user has access to the group" do - before do - group.add_developer(user) - end - - context "when the user is blocked" do - before do - user.block - end - - it "redirects to the sign in page" do - get :show, id: group.path - - expect(response).to redirect_to(new_user_session_path) - end - end - - context "when the user isn't blocked" do - it "redirects to the group's page" do - get :show, id: group.path - - expect(response).to redirect_to(group_path(group)) - end - end - end - - context "when the user doesn't have access to the group" do - it "responds with status 404" do - get :show, id: group.path - - expect(response).to have_http_status(404) - end - end - end - end - end - - context "when the namespace doesn't exist" do - context "when signed in" do - before do - sign_in(user) - end - - it "responds with status 404" do - get :show, id: "doesntexist" - - expect(response).to have_http_status(404) - end - end - - context "when not signed in" do - it "redirects to the sign in page" do - get :show, id: "doesntexist" - - expect(response).to redirect_to(new_user_session_path) - end - end - end - end -end diff --git a/spec/controllers/profiles/avatars_controller_spec.rb b/spec/controllers/profiles/avatars_controller_spec.rb index ad5855df0a4..4fa0462ccdf 100644 --- a/spec/controllers/profiles/avatars_controller_spec.rb +++ b/spec/controllers/profiles/avatars_controller_spec.rb @@ -8,7 +8,7 @@ describe Profiles::AvatarsController do controller.instance_variable_set(:@user, user) end - it 'destroy should remove avatar from DB' do + it 'removes avatar from DB by calling destroy' do delete :destroy @user = assigns(:user) expect(@user.avatar.present?).to be_falsey diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index 3a82083717f..6bcfae0fc13 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -6,7 +6,7 @@ describe Profiles::KeysController do describe '#new' do before { sign_in(user) } - it 'redirect to #index' do + it 'redirects to #index' do get :new expect(response).to redirect_to(profile_keys_path) @@ -15,7 +15,7 @@ describe Profiles::KeysController do describe "#get_keys" do describe "non existant user" do - it "should generally not work" do + it "does not generally work" do get :get_keys, username: 'not-existent' expect(response).not_to be_success @@ -23,19 +23,19 @@ describe Profiles::KeysController do end describe "user with no keys" do - it "should generally work" do + it "does generally work" do get :get_keys, username: user.username expect(response).to be_success end - it "should render all keys separated with a new line" do + it "renders all keys separated with a new line" do get :get_keys, username: user.username expect(response.body).to eq("") end - it "should respond with text/plain content type" do + it "responds with text/plain content type" do get :get_keys, username: user.username expect(response.content_type).to eq("text/plain") end @@ -47,13 +47,13 @@ describe Profiles::KeysController do user.keys << create(:another_key) end - it "should generally work" do + it "does generally work" do get :get_keys, username: user.username expect(response).to be_success end - it "should render all keys separated with a new line" do + it "renders all keys separated with a new line" do get :get_keys, username: user.username expect(response.body).not_to eq("") @@ -65,13 +65,13 @@ describe Profiles::KeysController do expect(response.body).to match(/AQDmTillFzNTrrGgwaCKaSj/) end - it "should not render the comment of the key" do + it "does not render the comment of the key" do get :get_keys, username: user.username expect(response.body).not_to match(/dummy@gitlab.com/) end - it "should respond with text/plain content type" do + it "responds with text/plain content type" do get :get_keys, username: user.username expect(response.content_type).to eq("text/plain") end diff --git a/spec/controllers/projects/avatars_controller_spec.rb b/spec/controllers/projects/avatars_controller_spec.rb index 4d724ca9ed0..f5ea097af8b 100644 --- a/spec/controllers/projects/avatars_controller_spec.rb +++ b/spec/controllers/projects/avatars_controller_spec.rb @@ -10,7 +10,7 @@ describe Projects::AvatarsController do controller.instance_variable_set(:@project, project) end - it 'destroy should remove avatar from DB' do + it 'removes avatar from DB by calling destroy' do delete :destroy, namespace_id: project.namespace.id, project_id: project.id expect(project.avatar.present?).to be_falsey expect(project).to be_valid diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 9444a50b1ce..52d13fb6f9e 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -5,7 +5,6 @@ describe Projects::BlobController do let(:user) { create(:user) } before do - user = create(:user) project.team << [user, :master] sign_in(user) diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb new file mode 100644 index 00000000000..da59642f24d --- /dev/null +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -0,0 +1,194 @@ +require 'spec_helper' + +describe Projects::Boards::IssuesController do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + let(:guest) { create(:user) } + + let(:planning) { create(:label, project: project, name: 'Planning') } + let(:development) { create(:label, project: project, name: 'Development') } + + let!(:list1) { create(:list, board: board, label: planning, position: 0) } + let!(:list2) { create(:list, board: board, label: development, position: 1) } + + before do + project.team << [user, :master] + project.team << [guest, :guest] + end + + describe 'GET index' do + context 'with valid list id' do + it 'returns issues that have the list label applied' do + johndoe = create(:user, avatar: fixture_file_upload(File.join(Rails.root, 'spec/fixtures/dk.png'))) + create(:labeled_issue, project: project, labels: [planning]) + create(:labeled_issue, project: project, labels: [development]) + create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + + list_issues user: user, board: board, list: list2 + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('issues') + expect(parsed_response.length).to eq 2 + end + end + + context 'with invalid board id' do + it 'returns a not found 404 response' do + list_issues user: user, board: 999, list: list2 + + expect(response).to have_http_status(404) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + list_issues user: user, board: board, list: 999 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + before do + allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability).to receive(:allowed?).with(user, :read_issue, project).and_return(false) + end + + it 'returns a forbidden 403 response' do + list_issues user: user, board: board, list: list2 + + expect(response).to have_http_status(403) + end + end + + def list_issues(user:, board:, list:) + sign_in(user) + + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + list_id: list.to_param + end + end + + describe 'POST create' do + context 'with valid params' do + it 'returns a successful 200 response' do + create_issue user: user, board: board, list: list1, title: 'New issue' + + expect(response).to have_http_status(200) + end + + it 'returns the created issue' do + create_issue user: user, board: board, list: list1, title: 'New issue' + + expect(response).to match_response_schema('issue') + end + end + + context 'with invalid params' do + context 'when title is nil' do + it 'returns an unprocessable entity 422 response' do + create_issue user: user, board: board, list: list1, title: nil + + expect(response).to have_http_status(422) + end + end + + context 'when list does not belongs to project board' do + it 'returns a not found 404 response' do + list = create(:list) + + create_issue user: user, board: board, list: list, title: 'New issue' + + expect(response).to have_http_status(404) + end + end + end + + context 'with unauthorized user' do + it 'returns a forbidden 403 response' do + create_issue user: guest, board: board, list: list1, title: 'New issue' + + expect(response).to have_http_status(403) + end + end + + def create_issue(user:, board:, list:, title:) + sign_in(user) + + post :create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + list_id: list.to_param, + issue: { title: title }, + format: :json + end + end + + describe 'PATCH update' do + let(:issue) { create(:labeled_issue, project: project, labels: [planning]) } + + context 'with valid params' do + it 'returns a successful 200 response' do + move user: user, board: board, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(200) + end + + it 'moves issue to the desired list' do + move user: user, board: board, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(issue.reload.labels).to contain_exactly(development) + end + end + + context 'with invalid params' do + it 'returns a unprocessable entity 422 response for invalid lists' do + move user: user, board: board, issue: issue, from_list_id: nil, to_list_id: nil + + expect(response).to have_http_status(422) + end + + it 'returns a not found 404 response for invalid board id' do + move user: user, board: 999, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(404) + end + + it 'returns a not found 404 response for invalid issue id' do + move user: user, board: board, issue: 999, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + let(:guest) { create(:user) } + + before do + project.team << [guest, :guest] + end + + it 'returns a forbidden 403 response' do + move user: guest, board: board, issue: issue, from_list_id: list1.id, to_list_id: list2.id + + expect(response).to have_http_status(403) + end + end + + def move(user:, board:, issue:, from_list_id:, to_list_id:) + sign_in(user) + + patch :update, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + id: issue.to_param, + from_list_id: from_list_id, + to_list_id: to_list_id, + format: :json + end + end +end diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb new file mode 100644 index 00000000000..34d6119429d --- /dev/null +++ b/spec/controllers/projects/boards/lists_controller_spec.rb @@ -0,0 +1,252 @@ +require 'spec_helper' + +describe Projects::Boards::ListsController do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + let(:guest) { create(:user) } + + before do + project.team << [user, :master] + project.team << [guest, :guest] + end + + describe 'GET index' do + it 'returns a successful 200 response' do + read_board_list user: user, board: board + + expect(response).to have_http_status(200) + expect(response.content_type).to eq 'application/json' + end + + it 'returns a list of board lists' do + create(:list, board: board) + + read_board_list user: user, board: board + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('lists') + expect(parsed_response.length).to eq 3 + end + + context 'with unauthorized user' do + before do + allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability).to receive(:allowed?).with(user, :read_list, project).and_return(false) + end + + it 'returns a forbidden 403 response' do + read_board_list user: user, board: board + + expect(response).to have_http_status(403) + end + end + + def read_board_list(user:, board:) + sign_in(user) + + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + format: :json + end + end + + describe 'POST create' do + context 'with valid params' do + let(:label) { create(:label, project: project, name: 'Development') } + + it 'returns a successful 200 response' do + create_board_list user: user, board: board, label_id: label.id + + expect(response).to have_http_status(200) + end + + it 'returns the created list' do + create_board_list user: user, board: board, label_id: label.id + + expect(response).to match_response_schema('list') + end + end + + context 'with invalid params' do + context 'when label is nil' do + it 'returns a not found 404 response' do + create_board_list user: user, board: board, label_id: nil + + expect(response).to have_http_status(404) + end + end + + context 'when label that does not belongs to project' do + it 'returns a not found 404 response' do + label = create(:label, name: 'Development') + + create_board_list user: user, board: board, label_id: label.id + + expect(response).to have_http_status(404) + end + end + end + + context 'with unauthorized user' do + it 'returns a forbidden 403 response' do + label = create(:label, project: project, name: 'Development') + + create_board_list user: guest, board: board, label_id: label.id + + expect(response).to have_http_status(403) + end + end + + def create_board_list(user:, board:, label_id:) + sign_in(user) + + post :create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + list: { label_id: label_id }, + format: :json + end + end + + describe 'PATCH update' do + let!(:planning) { create(:list, board: board, position: 0) } + let!(:development) { create(:list, board: board, position: 1) } + + context 'with valid position' do + it 'returns a successful 200 response' do + move user: user, board: board, list: planning, position: 1 + + expect(response).to have_http_status(200) + end + + it 'moves the list to the desired position' do + move user: user, board: board, list: planning, position: 1 + + expect(planning.reload.position).to eq 1 + end + end + + context 'with invalid position' do + it 'returns an unprocessable entity 422 response' do + move user: user, board: board, list: planning, position: 6 + + expect(response).to have_http_status(422) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + move user: user, board: board, list: 999, position: 1 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + it 'returns a forbidden 403 response' do + move user: guest, board: board, list: planning, position: 6 + + expect(response).to have_http_status(403) + end + end + + def move(user:, board:, list:, position:) + sign_in(user) + + patch :update, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + id: list.to_param, + list: { position: position }, + format: :json + end + end + + describe 'DELETE destroy' do + let!(:planning) { create(:list, board: board, position: 0) } + + context 'with valid list id' do + it 'returns a successful 200 response' do + remove_board_list user: user, board: board, list: planning + + expect(response).to have_http_status(200) + end + + it 'removes list from board' do + expect { remove_board_list user: user, board: board, list: planning }.to change(board.lists, :size).by(-1) + end + end + + context 'with invalid list id' do + it 'returns a not found 404 response' do + remove_board_list user: user, board: board, list: 999 + + expect(response).to have_http_status(404) + end + end + + context 'with unauthorized user' do + it 'returns a forbidden 403 response' do + remove_board_list user: guest, board: board, list: planning + + expect(response).to have_http_status(403) + end + end + + def remove_board_list(user:, board:, list:) + sign_in(user) + + delete :destroy, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + id: list.to_param, + format: :json + end + end + + describe 'POST generate' do + context 'when board lists is empty' do + it 'returns a successful 200 response' do + generate_default_lists user: user, board: board + + expect(response).to have_http_status(200) + end + + it 'returns the defaults lists' do + generate_default_lists user: user, board: board + + expect(response).to match_response_schema('lists') + end + end + + context 'when board lists is not empty' do + it 'returns an unprocessable entity 422 response' do + create(:list, board: board) + + generate_default_lists user: user, board: board + + expect(response).to have_http_status(422) + end + end + + context 'with unauthorized user' do + it 'returns a forbidden 403 response' do + generate_default_lists user: guest, board: board + + expect(response).to have_http_status(403) + end + end + + def generate_default_lists(user:, board:) + sign_in(user) + + post :generate, namespace_id: project.namespace.to_param, + project_id: project.to_param, + board_id: board.to_param, + format: :json + end + end +end diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb new file mode 100644 index 00000000000..cc19035740e --- /dev/null +++ b/spec/controllers/projects/boards_controller_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +describe Projects::BoardsController do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + sign_in(user) + end + + describe 'GET index' do + it 'creates a new project board when project does not have one' do + expect { list_boards }.to change(project.boards, :count).by(1) + end + + context 'when format is HTML' do + it 'renders template' do + list_boards + + expect(response).to render_template :index + expect(response.content_type).to eq 'text/html' + end + end + + context 'when format is JSON' do + it 'returns a list of project boards' do + create_list(:board, 2, project: project) + + list_boards format: :json + + parsed_response = JSON.parse(response.body) + + expect(response).to match_response_schema('boards') + expect(parsed_response.length).to eq 2 + end + end + + context 'with unauthorized user' do + before do + allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false) + end + + it 'returns a not found 404 response' do + list_boards + + expect(response).to have_http_status(404) + end + end + + def list_boards(format: :html) + get :index, namespace_id: project.namespace.to_param, + project_id: project.to_param, + format: format + end + end + + describe 'GET show' do + let!(:board) { create(:board, project: project) } + + context 'when format is HTML' do + it 'renders template' do + read_board board: board + + expect(response).to render_template :show + expect(response.content_type).to eq 'text/html' + end + end + + context 'when format is JSON' do + it 'returns project board' do + read_board board: board, format: :json + + expect(response).to match_response_schema('board') + end + end + + context 'with unauthorized user' do + before do + allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) + allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false) + end + + it 'returns a not found 404 response' do + read_board board: board + + expect(response).to have_http_status(404) + end + end + + context 'when board does not belong to project' do + it 'returns a not found 404 response' do + another_board = create(:board) + + read_board board: another_board + + expect(response).to have_http_status(404) + end + end + + def read_board(board:, format: :html) + get :show, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: board.to_param, + format: format + end + end +end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 940019b708b..646b097d74e 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -47,25 +47,25 @@ describe Projects::CommitController do end shared_examples "export as" do |format| - it "should generally work" do + it "does generally work" do go(id: commit.id, format: format) expect(response).to be_success end - it "should generate it" do + it "generates it" do expect_any_instance_of(Commit).to receive(:"to_#{format}") go(id: commit.id, format: format) end - it "should render it" do + it "renders it" do go(id: commit.id, format: format) expect(response.body).to eq(commit.send(:"to_#{format}")) end - it "should not escape Html" do + it "does not escape Html" do allow_any_instance_of(Commit).to receive(:"to_#{format}"). and_return('HTML entities &<>" ') @@ -88,7 +88,7 @@ describe Projects::CommitController do expect(response.body).to start_with("diff --git") end - it "should really only be a git diff without whitespace changes" do + it "is only be a git diff without whitespace changes" do go(id: '66eceea0db202bb39c4e445e8ca28689645366c5', format: format, w: 1) expect(response.body).to start_with("diff --git") @@ -102,15 +102,16 @@ describe Projects::CommitController do describe "as patch" do include_examples "export as", :patch let(:format) { :patch } + let(:commit2) { project.commit('498214de67004b1da3d820901307bed2a68a8ef6') } - it "should really be a git email patch" do - go(id: commit.id, format: format) + it "is a git email patch" do + go(id: commit2.id, format: format) - expect(response.body).to start_with("From #{commit.id}") + expect(response.body).to start_with("From #{commit2.id}") end - it "should contain a git diff" do - go(id: commit.id, format: format) + it "contains a git diff" do + go(id: commit2.id, format: format) expect(response.body).to match(/^diff --git/) end @@ -135,6 +136,8 @@ describe Projects::CommitController do describe "GET branches" do it "contains branch and tags information" do + commit = project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + get(:branches, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -147,7 +150,7 @@ describe Projects::CommitController do describe 'POST revert' do context 'when target branch is not provided' do - it 'should render the 404 page' do + it 'renders the 404 page' do post(:revert, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -159,7 +162,7 @@ describe Projects::CommitController do end context 'when the revert was successful' do - it 'should redirect to the commits page' do + it 'redirects to the commits page' do post(:revert, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -180,7 +183,7 @@ describe Projects::CommitController do id: commit.id) end - it 'should redirect to the commit page' do + it 'redirects to the commit page' do # Reverting a commit that has been already reverted. post(:revert, namespace_id: project.namespace.to_param, @@ -196,7 +199,7 @@ describe Projects::CommitController do describe 'POST cherry_pick' do context 'when target branch is not provided' do - it 'should render the 404 page' do + it 'renders the 404 page' do post(:cherry_pick, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -208,7 +211,7 @@ describe Projects::CommitController do end context 'when the cherry-pick was successful' do - it 'should redirect to the commits page' do + it 'redirects to the commits page' do post(:cherry_pick, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -229,7 +232,7 @@ describe Projects::CommitController do id: master_pickable_commit.id) end - it 'should redirect to the commit page' do + it 'redirects to the commit page' do # Cherry-picking a commit that has been already cherry-picked. post(:cherry_pick, namespace_id: project.namespace.to_param, @@ -254,16 +257,17 @@ describe Projects::CommitController do end let(:existing_path) { '.gitmodules' } + let(:commit2) { project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e') } context 'when the commit exists' do context 'when the user has access to the project' do context 'when the path exists in the diff' do it 'enables diff notes' do - diff_for_path(id: commit.id, old_path: existing_path, new_path: existing_path) + diff_for_path(id: commit2.id, old_path: existing_path, new_path: existing_path) expect(assigns(:diff_notes_disabled)).to be_falsey expect(assigns(:comments_target)).to eq(noteable_type: 'Commit', - commit_id: commit.id) + commit_id: commit2.id) end it 'only renders the diffs for the path given' do @@ -272,7 +276,7 @@ describe Projects::CommitController do meth.call(diffs) end - diff_for_path(id: commit.id, old_path: existing_path, new_path: existing_path) + diff_for_path(id: commit2.id, old_path: existing_path, new_path: existing_path) end end diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb index 7d8089c4bc6..1ac7e03a2db 100644 --- a/spec/controllers/projects/commits_controller_spec.rb +++ b/spec/controllers/projects/commits_controller_spec.rb @@ -10,15 +10,38 @@ describe Projects::CommitsController do end describe "GET show" do - context "as atom feed" do - it "should render as atom" do - get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - id: "master", - format: "atom") - expect(response).to be_success - expect(response.content_type).to eq('application/atom+xml') + context "when the ref name ends in .atom" do + render_views + + context "when the ref does not exist with the suffix" do + it "renders as atom" do + get(:show, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: "master.atom") + + expect(response).to be_success + expect(response.content_type).to eq('application/atom+xml') + end + end + + context "when the ref exists with the suffix" do + before do + commit = project.repository.commit('master') + + allow_any_instance_of(Repository).to receive(:commit).and_call_original + allow_any_instance_of(Repository).to receive(:commit).with('master.atom').and_return(commit) + + get(:show, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: "master.atom") + end + + it "renders as HTML" do + expect(response).to be_success + expect(response.content_type).to eq('text/html') + end end end end diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index ed4cc36de58..7a57801c437 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -11,7 +11,7 @@ describe Projects::CompareController do project.team << [user, :master] end - it 'compare should show some diffs' do + it 'compare shows some diffs' do get(:show, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -23,7 +23,7 @@ describe Projects::CompareController do expect(assigns(:commits).length).to be >= 1 end - it 'compare should show some diffs with ignore whitespace change option' do + it 'compare shows some diffs with ignore whitespace change option' do get(:show, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -41,7 +41,7 @@ describe Projects::CompareController do end describe 'non-existent refs' do - it 'invalid source ref' do + it 'uses invalid source ref' do get(:show, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -53,7 +53,7 @@ describe Projects::CompareController do expect(assigns(:commits)).to eq([]) end - it 'invalid target ref' do + it 'uses invalid target ref' do get(:show, namespace_id: project.namespace.to_param, project_id: project.to_param, diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb new file mode 100644 index 00000000000..ff617fea847 --- /dev/null +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -0,0 +1,125 @@ +require 'spec_helper' + +describe Projects::DiscussionsController do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:discussion) { note.discussion } + + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + merge_request_id: merge_request, + id: note.discussion_id + } + end + + describe 'POST resolve' do + before do + sign_in user + end + + context "when the user is not authorized to resolve the discussion" do + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the discussion" do + before do + project.team << [user, :developer] + end + + context "when the discussion is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the discussion is resolvable" do + it "resolves the discussion" do + post :resolve, request_params + + expect(note.reload.discussion.resolved?).to be true + expect(note.reload.discussion.resolved_by).to eq(user) + end + + it "sends notifications if all discussions are resolved" do + expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request) + + post :resolve, request_params + end + + it "returns the name of the resolving user" do + post :resolve, request_params + + expect(JSON.parse(response.body)["resolved_by"]).to eq(user.name) + end + + it "returns status 200" do + post :resolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end + + describe 'DELETE unresolve' do + before do + sign_in user + + note.discussion.resolve!(user) + end + + context "when the user is not authorized to resolve the discussion" do + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the discussion" do + before do + project.team << [user, :developer] + end + + context "when the discussion is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the discussion is resolvable" do + it "unresolves the discussion" do + delete :unresolve, request_params + + expect(note.reload.discussion.resolved?).to be false + end + + it "returns status 200" do + delete :unresolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end +end diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index f66bcb8099c..ac3469cb8a9 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -16,7 +16,7 @@ describe Projects::ForksController do context 'when fork is public' do before { forked_project.update_attribute(:visibility_level, Project::PUBLIC) } - it 'should be visible for non logged in users' do + it 'is visible for non logged in users' do get_forks expect(assigns[:forks]).to be_present @@ -28,7 +28,7 @@ describe Projects::ForksController do forked_project.update_attributes(visibility_level: Project::PRIVATE, group: group) end - it 'should not be visible for non logged in users' do + it 'is not be visible for non logged in users' do get_forks expect(assigns[:forks]).to be_blank @@ -38,7 +38,7 @@ describe Projects::ForksController do before { sign_in(project.creator) } context 'when user is not a Project member neither a group member' do - it 'should not see the Project listed' do + it 'does not see the Project listed' do get_forks expect(assigns[:forks]).to be_blank @@ -48,7 +48,7 @@ describe Projects::ForksController do context 'when user is a member of the Project' do before { forked_project.team << [project.creator, :developer] } - it 'should see the project listed' do + it 'sees the project listed' do get_forks expect(assigns[:forks]).to be_present @@ -58,7 +58,7 @@ describe Projects::ForksController do context 'when user is a member of the Group' do before { forked_project.group.add_developer(project.creator) } - it 'should see the project listed' do + it 'sees the project listed' do get_forks expect(assigns[:forks]).to be_present diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb new file mode 100644 index 00000000000..74e6603b0cb --- /dev/null +++ b/spec/controllers/projects/graphs_controller_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Projects::GraphsController do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + sign_in(user) + project.team << [user, :master] + end + + describe 'GET #languages' do + let(:linguist_repository) do + double(languages: { + 'Ruby' => 1000, + 'CoffeeScript' => 350, + 'PowerShell' => 15 + }) + end + + let(:expected_values) do + ps_color = "##{Digest::SHA256.hexdigest('PowerShell')[0...6]}" + [ + # colors from Linguist: + { label: "Ruby", color: "#701516", highlight: "#701516" }, + { label: "CoffeeScript", color: "#244776", highlight: "#244776" }, + # colors from SHA256 fallback: + { label: "PowerShell", color: ps_color, highlight: ps_color } + ] + end + + before do + allow(Linguist::Repository).to receive(:new).and_return(linguist_repository) + end + + it 'sets the correct colour according to language' do + get(:languages, namespace_id: project.namespace.path, project_id: project.path, id: 'master') + + expected_values.each do |val| + expect(assigns(:languages)).to include(a_hash_including(val)) + end + end + end +end diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index fbe8758dda7..b9d9117c928 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -1,8 +1,9 @@ require 'spec_helper' describe Projects::GroupLinksController do - let(:project) { create(:project, :private) } let(:group) { create(:group, :private) } + let(:group2) { create(:group, :private) } + let(:project) { create(:project, :private, group: group2) } let(:user) { create(:user) } before do @@ -46,5 +47,39 @@ describe Projects::GroupLinksController do expect(group.shared_projects).not_to include project end end + + context 'when project group id equal link group id' do + before do + post(:create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + link_group_id: group2.id, + link_group_access: ProjectGroupLink.default_access) + end + + it 'does not share project with selected group' do + expect(group2.shared_projects).not_to include project + end + + it 'redirects to project group links page' do + expect(response).to redirect_to( + namespace_project_group_links_path(project.namespace, project) + ) + end + end + + context 'when link group id is not present' do + before do + post(:create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + link_group_access: ProjectGroupLink.default_access) + end + + it 'redirects to project group links page' do + expect(response).to redirect_to( + namespace_project_group_links_path(project.namespace, project) + ) + expect(flash[:alert]).to eq('Please select a group.') + end + end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index ec820de3d09..90419368f22 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -8,13 +8,13 @@ describe Projects::IssuesController do describe "GET #index" do context 'external issue tracker' do it 'redirects to the external issue tracker' do - external = double(issues_url: 'https://example.com/issues') + external = double(project_path: 'https://example.com/project') allow(project).to receive(:external_issue_tracker).and_return(external) controller.instance_variable_set(:@project, project) get :index, namespace_id: project.namespace.path, project_id: project - expect(response).to redirect_to('https://example.com/issues') + expect(response).to redirect_to('https://example.com/project') end end @@ -30,7 +30,7 @@ describe Projects::IssuesController do expect(response).to have_http_status(200) end - it "return 301 if request path doesn't match project path" do + it "returns 301 if request path doesn't match project path" do get :index, namespace_id: project.namespace.path, project_id: project.path.upcase expect(response).to redirect_to(namespace_project_issues_path(project.namespace, project)) @@ -119,21 +119,21 @@ describe Projects::IssuesController do let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } describe 'GET #index' do - it 'should not list confidential issues for guests' do + it 'does not list confidential issues for guests' do sign_out(:user) get_issues expect(assigns(:issues)).to eq [issue] end - it 'should not list confidential issues for non project members' do + it 'does not list confidential issues for non project members' do sign_in(non_member) get_issues expect(assigns(:issues)).to eq [issue] end - it 'should not list confidential issues for project members with guest role' do + it 'does not list confidential issues for project members with guest role' do sign_in(member) project.team << [member, :guest] @@ -142,7 +142,7 @@ describe Projects::IssuesController do expect(assigns(:issues)).to eq [issue] end - it 'should list confidential issues for author' do + it 'lists confidential issues for author' do sign_in(author) get_issues @@ -150,7 +150,7 @@ describe Projects::IssuesController do expect(assigns(:issues)).not_to include request_forgery_timing_attack end - it 'should list confidential issues for assignee' do + it 'lists confidential issues for assignee' do sign_in(assignee) get_issues @@ -158,7 +158,7 @@ describe Projects::IssuesController do expect(assigns(:issues)).to include request_forgery_timing_attack end - it 'should list confidential issues for project members' do + it 'lists confidential issues for project members' do sign_in(member) project.team << [member, :developer] @@ -168,7 +168,7 @@ describe Projects::IssuesController do expect(assigns(:issues)).to include request_forgery_timing_attack end - it 'should list confidential issues for admin' do + it 'lists confidential issues for admin' do sign_in(admin) get_issues @@ -274,8 +274,8 @@ describe Projects::IssuesController do describe 'POST #create' do context 'Akismet is enabled' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) end def post_spam_issue @@ -300,6 +300,52 @@ describe Projects::IssuesController do expect(spam_logs[0].title).to eq('Spam Title') end end + + context 'user agent details are saved' do + before do + request.env['action_dispatch.remote_ip'] = '127.0.0.1' + end + + def post_new_issue + sign_in(user) + project = create(:empty_project, :public) + post :create, { + namespace_id: project.namespace.to_param, + project_id: project.to_param, + issue: { title: 'Title', description: 'Description' } + } + end + + it 'creates a user agent detail' do + expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1) + end + end + end + + describe 'POST #mark_as_spam' do + context 'properly submits to Akismet' do + before do + allow_any_instance_of(AkismetService).to receive_messages(submit_spam: true) + allow_any_instance_of(ApplicationSetting).to receive_messages(akismet_enabled: true) + end + + def post_spam + admin = create(:admin) + create(:user_agent_detail, subject: issue) + project.team << [admin, :master] + sign_in(admin) + post :mark_as_spam, { + namespace_id: project.namespace.path, + project_id: project.path, + id: issue.iid + } + end + + it 'updates issue' do + post_spam + expect(issue.submittable_as_spam?).to be_falsey + end + end end describe "DELETE #destroy" do @@ -324,6 +370,12 @@ describe Projects::IssuesController do expect(response).to have_http_status(302) expect(controller).to set_flash[:notice].to(/The issue was successfully deleted\./).now end + + it 'delegates the update of the todos count cache to TodoService' do + expect_any_instance_of(TodoService).to receive(:destroy_issue).with(issue, owner).once + + delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: issue.iid + end end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 1f6bc84dfe8..d509f0f2b96 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do let(:project) { create(:project) } let(:user) { create(:user) } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } + let(:merge_request_with_conflicts) do + create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end before do sign_in(user) @@ -36,7 +41,7 @@ describe Projects::MergeRequestsController do describe "GET show" do shared_examples "export merge as" do |format| - it "should generally work" do + it "does generally work" do get(:show, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -46,7 +51,7 @@ describe Projects::MergeRequestsController do expect(response).to be_success end - it "should generate it" do + it "generates it" do expect_any_instance_of(MergeRequest).to receive(:"to_#{format}") get(:show, @@ -56,7 +61,7 @@ describe Projects::MergeRequestsController do format: format) end - it "should render it" do + it "renders it" do get(:show, namespace_id: project.namespace.to_param, project_id: project.to_param, @@ -66,7 +71,7 @@ describe Projects::MergeRequestsController do expect(response.body).to eq(merge_request.send(:"to_#{format}").to_s) end - it "should not escape Html" do + it "does not escape Html" do allow_any_instance_of(MergeRequest).to receive(:"to_#{format}"). and_return('HTML entities &<>" ') @@ -118,7 +123,7 @@ describe Projects::MergeRequestsController do context 'when filtering by opened state' do context 'with opened merge requests' do - it 'should list those merge requests' do + it 'lists those merge requests' do get_merge_requests expect(assigns(:merge_requests)).to include(merge_request) @@ -131,7 +136,7 @@ describe Projects::MergeRequestsController do merge_request.reopen! end - it 'should list those merge requests' do + it 'lists those merge requests' do get_merge_requests expect(assigns(:merge_requests)).to include(merge_request) @@ -165,6 +170,35 @@ describe Projects::MergeRequestsController do expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request]) expect(merge_request.reload.closed?).to be_truthy end + + it 'allows editing of a closed merge request' do + merge_request.close! + + put :update, + namespace_id: project.namespace.path, + project_id: project.path, + id: merge_request.iid, + merge_request: { + title: 'New title' + } + + expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request]) + expect(merge_request.reload.title).to eq 'New title' + end + + it 'does not allow to update target branch closed merge request' do + merge_request.close! + + put :update, + namespace_id: project.namespace.path, + project_id: project.path, + id: merge_request.iid, + merge_request: { + target_branch: 'new_branch' + } + + expect { merge_request.reload.target_branch }.not_to change { merge_request.target_branch } + end end end @@ -286,6 +320,12 @@ describe Projects::MergeRequestsController do expect(response).to have_http_status(302) expect(controller).to set_flash[:notice].to(/The merge request was successfully deleted\./).now end + + it 'delegates the update of the todos count cache to TodoService' do + expect_any_instance_of(TodoService).to receive(:destroy_merge_request).with(merge_request, owner).once + + delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid + end end end @@ -523,4 +563,227 @@ describe Projects::MergeRequestsController do end end end + + describe 'GET conflicts' do + let(:json_response) { JSON.parse(response.body) } + + context 'when the conflicts cannot be resolved in the UI' do + before do + allow_any_instance_of(Gitlab::Conflict::Parser). + to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + get :conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'returns a 200 status code' do + expect(response).to have_http_status(:ok) + end + + it 'returns JSON with a message' do + expect(json_response.keys).to contain_exactly('message', 'type') + end + end + + context 'with valid conflicts' do + before do + get :conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'includes meta info about the MR' do + expect(json_response['commit_message']).to include('Merge branch') + expect(json_response['commit_sha']).to match(/\h{40}/) + expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch) + expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch) + end + + it 'includes each file that has conflicts' do + filenames = json_response['files'].map { |file| file['new_path'] } + + expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb') + end + + it 'splits files into sections with lines' do + json_response['files'].each do |file| + file['sections'].each do |section| + expect(section).to include('conflict', 'lines') + + section['lines'].each do |line| + if section['conflict'] + expect(line['type']).to be_in(['old', 'new']) + expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer)) + else + if line['type'].nil? + expect(line['old_line']).not_to eq(nil) + expect(line['new_line']).not_to eq(nil) + else + expect(line['type']).to eq('match') + expect(line['old_line']).to eq(nil) + expect(line['new_line']).to eq(nil) + end + end + end + end + end + end + + it 'has unique section IDs across files' do + section_ids = json_response['files'].flat_map do |file| + file['sections'].map { |section| section['id'] }.compact + end + + expect(section_ids.uniq).to eq(section_ids) + end + end + end + + context 'POST remove_wip' do + it 'removes the wip status' do + merge_request.title = merge_request.wip_title + merge_request.save + + post :remove_wip, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project.to_param, + id: merge_request.iid + + expect(merge_request.reload.title).to eq(merge_request.wipless_title) + end + end + + context 'POST resolve_conflicts' do + let(:json_response) { JSON.parse(response.body) } + let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha } + + def resolve_conflicts(sections) + post :resolve_conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project.to_param, + id: merge_request_with_conflicts.iid, + format: 'json', + sections: sections, + commit_message: 'Commit message' + end + + context 'with valid params' do + before do + resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin') + end + + it 'creates a new commit on the branch' do + expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha) + expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message') + end + + it 'returns an OK response' do + expect(response).to have_http_status(:ok) + end + end + + context 'when sections are missing' do + before do + resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head') + end + + it 'returns a 400 error' do + expect(response).to have_http_status(:bad_request) + end + + it 'has a message with the name of the first missing section' do + expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9') + end + + it 'does not create a new commit' do + expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) + end + end + end + + describe 'POST assign_related_issues' do + let(:issue1) { create(:issue, project: project) } + let(:issue2) { create(:issue, project: project) } + + def post_assign_issues + merge_request.update!(description: "Closes #{issue1.to_reference} and #{issue2.to_reference}", + author: user, + source_branch: 'feature', + target_branch: 'master') + + post :assign_related_issues, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: merge_request.iid + end + + it 'shows a flash message on success' do + post_assign_issues + + expect(flash[:notice]).to eq '2 issues have been assigned to you' + end + + it 'correctly pluralizes flash message on success' do + issue2.update!(assignee: user) + + post_assign_issues + + expect(flash[:notice]).to eq '1 issue has been assigned to you' + end + + it 'calls MergeRequests::AssignIssuesService' do + expect(MergeRequests::AssignIssuesService).to receive(:new). + with(project, user, merge_request: merge_request). + and_return(double(execute: { count: 1 })) + + post_assign_issues + end + + it 'is skipped when not signed in' do + project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + sign_out(:user) + + expect(MergeRequests::AssignIssuesService).not_to receive(:new) + + post_assign_issues + end + end + + describe 'GET ci_environments_status' do + context 'when the environment is from a forked project' do + let!(:forked) { create(:project) } + let!(:environment) { create(:environment, project: forked) } + let!(:deployment) { create(:deployment, environment: environment, sha: forked.commit.id, ref: 'master') } + let(:json_response) { JSON.parse(response.body) } + let(:admin) { create(:admin) } + + let(:merge_request) do + create(:forked_project_link, forked_to_project: forked, + forked_from_project: project) + + create(:merge_request, source_project: forked, target_project: project) + end + + before do + forked.team << [user, :master] + + get :ci_environments_status, + namespace_id: merge_request.project.namespace.to_param, + project_id: merge_request.project.to_param, + id: merge_request.iid, format: 'json' + end + + it 'links to the environment on that project' do + expect(json_response.first['url']).to match /#{forked.path_with_namespace}/ + end + end + end end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index d173bb350f1..4e3ef5dc6fa 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -14,7 +14,7 @@ describe Projects::MilestonesController do end describe "#destroy" do - it "should remove milestone" do + it "removes milestone" do expect(issue.milestone_id).to eq(milestone.id) delete :destroy, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid, format: :js diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 75590c1ed4f..92e38b02615 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -1,4 +1,4 @@ -require('spec_helper') +require 'spec_helper' describe Projects::NotesController do let(:user) { create(:user) } @@ -6,7 +6,15 @@ describe Projects::NotesController do let(:issue) { create(:issue, project: project) } let(:note) { create(:note, noteable: issue, project: project) } - describe 'POST #toggle_award_emoji' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + id: note + } + end + + describe 'POST toggle_award_emoji' do before do sign_in(user) project.team << [user, :developer] @@ -14,23 +22,132 @@ describe Projects::NotesController do it "toggles the award emoji" do expect do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: note.id, name: "thumbsup") + post(:toggle_award_emoji, request_params.merge(name: "thumbsup")) end.to change { note.award_emoji.count }.by(1) expect(response).to have_http_status(200) end it "removes the already awarded emoji" do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: note.id, name: "thumbsup") + post(:toggle_award_emoji, request_params.merge(name: "thumbsup")) expect do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: note.id, name: "thumbsup") + post(:toggle_award_emoji, request_params.merge(name: "thumbsup")) end.to change { AwardEmoji.count }.by(-1) expect(response).to have_http_status(200) end end + + describe "resolving and unresolving" do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + + describe 'POST resolve' do + before do + sign_in user + end + + context "when the user is not authorized to resolve the note" do + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the note" do + before do + project.team << [user, :developer] + end + + context "when the note is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + post :resolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the note is resolvable" do + it "resolves the note" do + post :resolve, request_params + + expect(note.reload.resolved?).to be true + expect(note.reload.resolved_by).to eq(user) + end + + it "sends notifications if all discussions are resolved" do + expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request) + + post :resolve, request_params + end + + it "returns the name of the resolving user" do + post :resolve, request_params + + expect(JSON.parse(response.body)["resolved_by"]).to eq(user.name) + end + + it "returns status 200" do + post :resolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end + + describe 'DELETE unresolve' do + before do + sign_in user + + note.resolve!(user) + end + + context "when the user is not authorized to resolve the note" do + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the user is authorized to resolve the note" do + before do + project.team << [user, :developer] + end + + context "when the note is not resolvable" do + before do + note.update(system: true) + end + + it "returns status 404" do + delete :unresolve, request_params + + expect(response).to have_http_status(404) + end + end + + context "when the note is resolvable" do + it "unresolves the note" do + delete :unresolve, request_params + + expect(note.reload.resolved?).to be false + end + + it "returns status 200" do + delete :unresolve, request_params + + expect(response).to have_http_status(200) + end + end + end + end + end end diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 5e2a8cf3849..074f85157de 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -135,11 +135,11 @@ describe Projects::ProjectMembersController do context 'when member is not found' do before { sign_in(user) } - it 'returns 403' do + it 'returns 404' do delete :leave, namespace_id: project.namespace, project_id: project - expect(response).to have_http_status(403) + expect(response).to have_http_status(404) end end diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb index 596d8d34b7c..da6112a13f7 100644 --- a/spec/controllers/projects/protected_branches_controller_spec.rb +++ b/spec/controllers/projects/protected_branches_controller_spec.rb @@ -3,7 +3,7 @@ require('spec_helper') describe Projects::ProtectedBranchesController do describe "GET #index" do let(:project) { create(:project_empty_repo, :public) } - it "redirect empty repo to projects page" do + it "redirects empty repo to projects page" do get(:index, namespace_id: project.namespace.to_param, project_id: project.to_param) end end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 48f799d8ca1..04bd9a01f7b 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -24,7 +24,7 @@ describe Projects::RawController do context 'image header' do let(:id) { 'master/files/images/6049019_460s.jpg' } - it 'set image content type header' do + it 'sets image content type header' do get(:show, namespace_id: public_project.namespace.to_param, project_id: public_project.to_param, diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb index 2fe3c263524..38e02a46626 100644 --- a/spec/controllers/projects/repositories_controller_spec.rb +++ b/spec/controllers/projects/repositories_controller_spec.rb @@ -8,7 +8,7 @@ describe Projects::RepositoriesController do it 'responds with redirect in correct format' do get :archive, namespace_id: project.namespace.path, project_id: project.path, format: "zip" - expect(response.content_type).to start_with 'text/html' + expect(response.header["Content-Type"]).to start_with('text/html') expect(response).to be_redirect end end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index ccd8c741c83..2e44b5128b4 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -19,7 +19,7 @@ describe Projects::ServicesController do describe "#test" do context 'success' do - it "should redirect and show success message" do + it "redirects and show success message" do expect(service).to receive(:test).and_return({ success: true, result: 'done' }) get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html expect(response.status).to redirect_to('/') @@ -28,7 +28,7 @@ describe Projects::ServicesController do end context 'failure' do - it "should redirect and show failure message" do + it "redirects and show failure message" do expect(service).to receive(:test).and_return({ success: false, result: 'Bad test' }) get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html expect(response.status).to redirect_to('/') @@ -49,4 +49,20 @@ describe Projects::ServicesController do let!(:referrer) { nil } end end + + describe 'PUT #update' do + context 'on successful update' do + it 'sets the flash' do + expect(service).to receive(:to_param).and_return('hipchat') + + put :update, + namespace_id: project.namespace.id, + project_id: project.id, + id: service.id, + service: { active: false } + + expect(flash[:notice]).to eq 'Successfully updated.' + end + end + end end diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index b8a28f43707..72a3ebf2ebd 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Projects::SnippetsController do - let(:project) { create(:project_empty_repo, :public, snippets_enabled: true) } + let(:project) { create(:project_empty_repo, :public) } let(:user) { create(:user) } let(:user2) { create(:user) } diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb index a6995145cc1..5e661c2c41d 100644 --- a/spec/controllers/projects/tags_controller_spec.rb +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -17,4 +17,18 @@ describe Projects::TagsController do expect(assigns(:releases)).not_to include(invalid_release) end end + + describe 'GET show' do + before { get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, id: id } + + context "valid tag" do + let(:id) { 'v1.0.0' } + it { is_expected.to respond_with(:success) } + end + + context "invalid tag" do + let(:id) { 'latest' } + it { is_expected.to respond_with(:not_found) } + end + end end diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb new file mode 100644 index 00000000000..19a152bcb05 --- /dev/null +++ b/spec/controllers/projects/templates_controller_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Projects::TemplatesController do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:file_path_1) { '.gitlab/issue_templates/bug.md' } + let(:body) { JSON.parse(response.body) } + + before do + project.team << [user, :developer] + sign_in(user) + end + + before do + project.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + end + + describe '#show' do + it 'renders template name and content as json' do + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(200) + expect(body["name"]).to eq("bug") + expect(body["content"]).to eq("something valid") + end + + it 'renders 404 when unauthorized' do + sign_in(user2) + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(404) + end + + it 'renders 404 when template type is not found' do + sign_in(user) + get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(404) + end + + it 'renders 404 without errors' do + sign_in(user) + expect { get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) }.not_to raise_error + end + end +end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 3edce4d339c..da0fdce39db 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -63,6 +63,28 @@ describe ProjectsController do end end + context "project with broken repo" do + let(:empty_project) { create(:project_broken_repo, :public) } + + before { sign_in(user) } + + User.project_views.keys.each do |project_view| + context "with #{project_view} view set" do + before do + user.update_attributes(project_view: project_view) + + get :show, namespace_id: empty_project.namespace.path, id: empty_project.path + end + + it "renders the empty project view" do + allow(Project).to receive(:repo).and_raise(Gitlab::Git::Repository::NoRepository) + + expect(response).to render_template('projects/no_repo') + end + end + end + end + context "rendering default project view" do render_views @@ -128,7 +150,7 @@ describe ProjectsController do context "when the url contains .atom" do let(:public_project_with_dot_atom) { build(:project, :public, name: 'my.atom', path: 'my.atom') } - it 'expect an error creating the project' do + it 'expects an error creating the project' do expect(public_project_with_dot_atom).not_to be_valid end end @@ -181,6 +203,25 @@ describe ProjectsController do expect(response).to have_http_status(302) expect(response).to redirect_to(dashboard_projects_path) end + + context "when the project is forked" do + let(:project) { create(:project) } + let(:fork_project) { create(:project, forked_from_project: project) } + let(:merge_request) do + create(:merge_request, + source_project: fork_project, + target_project: project) + end + + it "closes all related merge requests" do + project.merge_requests << merge_request + sign_in(admin) + + delete :destroy, namespace_id: fork_project.namespace.path, id: fork_project.path + + expect(merge_request.reload.state).to eq('closed') + end + end end describe "POST #toggle_star" do @@ -222,7 +263,7 @@ describe ProjectsController do create(:forked_project_link, forked_to_project: project_fork) end - it 'should remove fork from project' do + it 'removes fork from project' do delete(:remove_fork, namespace_id: project_fork.namespace.to_param, id: project_fork.to_param, format: :js) @@ -236,7 +277,7 @@ describe ProjectsController do context 'when project not forked' do let(:unforked_project) { create(:project, namespace: user.namespace) } - it 'should do nothing if project was not forked' do + it 'does nothing if project was not forked' do delete(:remove_fork, namespace_id: unforked_project.namespace.to_param, id: unforked_project.to_param, format: :js) @@ -256,7 +297,7 @@ describe ProjectsController do end describe "GET refs" do - it "should get a list of branches and tags" do + it "gets a list of branches and tags" do get :refs, namespace_id: public_project.namespace.path, id: public_project.path parsed_body = JSON.parse(response.body) @@ -265,7 +306,7 @@ describe ProjectsController do expect(parsed_body["Commits"]).to be_nil end - it "should get a list of branches, tags and commits" do + it "gets a list of branches, tags and commits" do get :refs, namespace_id: public_project.namespace.path, id: public_project.path, ref: "123456" parsed_body = JSON.parse(response.body) diff --git a/spec/controllers/sent_notifications_controller_spec.rb b/spec/controllers/sent_notifications_controller_spec.rb index 9ced397bd4a..191e290a118 100644 --- a/spec/controllers/sent_notifications_controller_spec.rb +++ b/spec/controllers/sent_notifications_controller_spec.rb @@ -1,25 +1,108 @@ require 'rails_helper' describe SentNotificationsController, type: :controller do - let(:user) { create(:user) } - let(:issue) { create(:issue, author: user) } - let(:sent_notification) { create(:sent_notification, noteable: issue) } + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:sent_notification) { create(:sent_notification, noteable: issue, recipient: user) } - describe 'GET #unsubscribe' do - it 'returns a 404 when calling without existing id' do - get(:unsubscribe, id: '0' * 32) + let(:issue) do + create(:issue, project: project, author: user) do |issue| + issue.subscriptions.create(user: user, subscribed: true) + end + end + + describe 'GET unsubscribe' do + context 'when the user is not logged in' do + context 'when the force param is passed' do + before { get(:unsubscribe, id: sent_notification.reply_key, force: true) } + + it 'unsubscribes the user' do + expect(issue.subscribed?(user)).to be_falsey + end + + it 'sets the flash message' do + expect(controller).to set_flash[:notice].to(/unsubscribed/).now + end + + it 'redirects to the login page' do + expect(response).to redirect_to(new_user_session_path) + end + end + + context 'when the force param is not passed' do + before { get(:unsubscribe, id: sent_notification.reply_key) } + + it 'does not unsubscribe the user' do + expect(issue.subscribed?(user)).to be_truthy + end - expect(response.status).to be 404 + it 'does not set the flash message' do + expect(controller).not_to set_flash[:notice] + end + + it 'redirects to the login page' do + expect(response).to render_template :unsubscribe + end + end end - context 'calling with id' do - it 'shows a flash message to the user' do - get(:unsubscribe, id: sent_notification.reply_key) + context 'when the user is logged in' do + before { sign_in(user) } + + context 'when the ID passed does not exist' do + before { get(:unsubscribe, id: sent_notification.reply_key.reverse) } + + it 'does not unsubscribe the user' do + expect(issue.subscribed?(user)).to be_truthy + end + + it 'does not set the flash message' do + expect(controller).not_to set_flash[:notice] + end + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'when the force param is passed' do + before { get(:unsubscribe, id: sent_notification.reply_key, force: true) } + + it 'unsubscribes the user' do + expect(issue.subscribed?(user)).to be_falsey + end + + it 'sets the flash message' do + expect(controller).to set_flash[:notice].to(/unsubscribed/).now + end + + it 'redirects to the issue page' do + expect(response). + to redirect_to(namespace_project_issue_path(project.namespace, project, issue)) + end + end + + context 'when the force param is not passed' do + let(:merge_request) do + create(:merge_request, source_project: project, author: user) do |merge_request| + merge_request.subscriptions.create(user: user, subscribed: true) + end + end + let(:sent_notification) { create(:sent_notification, noteable: merge_request, recipient: user) } + before { get(:unsubscribe, id: sent_notification.reply_key) } + + it 'unsubscribes the user' do + expect(merge_request.subscribed?(user)).to be_falsey + end - expect(response.status).to be 302 + it 'sets the flash message' do + expect(controller).to set_flash[:notice].to(/unsubscribed/).now + end - expect(response).to redirect_to new_user_session_path - expect(controller).to set_flash[:notice].to(/unsubscribed/).now + it 'redirects to the merge request page' do + expect(response). + to redirect_to(namespace_project_merge_request_path(project.namespace, project, merge_request)) + end end end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 4e9bfb0c69b..48d69377461 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -109,6 +109,44 @@ describe SessionsController do end end + context 'when the user is on their last attempt' do + before do + user.update(failed_attempts: User.maximum_attempts.pred) + end + + context 'when OTP is valid' do + it 'authenticates correctly' do + authenticate_2fa(otp_attempt: user.current_otp) + + expect(subject.current_user).to eq user + end + end + + context 'when OTP is invalid' do + before { authenticate_2fa(otp_attempt: 'invalid') } + + it 'does not authenticate' do + expect(subject.current_user).not_to eq user + end + + it 'warns about invalid login' do + expect(response).to set_flash.now[:alert] + .to /Invalid Login or password/ + end + + it 'locks the user' do + expect(user.reload).to be_access_locked + end + + it 'keeps the user locked on future login attempts' do + post(:create, user: { login: user.username, password: user.password }) + + expect(response) + .to set_flash.now[:alert].to /Invalid Login or password/ + end + end + end + context 'when another user does not have 2FA enabled' do let(:another_user) { create(:user) } @@ -136,6 +174,29 @@ describe SessionsController do post(:create, { user: user_params }, { otp_user_id: user.id }) end + context 'remember_me field' do + it 'sets a remember_user_token cookie when enabled' do + allow(U2fRegistration).to receive(:authenticate).and_return(true) + allow(controller).to receive(:find_user).and_return(user) + expect(controller). + to receive(:remember_me).with(user).and_call_original + + authenticate_2fa_u2f(remember_me: '1', login: user.username, device_response: "{}") + + expect(response.cookies['remember_user_token']).to be_present + end + + it 'does nothing when disabled' do + allow(U2fRegistration).to receive(:authenticate).and_return(true) + allow(controller).to receive(:find_user).and_return(user) + expect(controller).not_to receive(:remember_me) + + authenticate_2fa_u2f(remember_me: '0', login: user.username, device_response: "{}") + + expect(response.cookies['remember_user_token']).to be_nil + end + end + it "creates an audit log record" do allow(U2fRegistration).to receive(:authenticate).and_return(true) expect { authenticate_2fa_u2f(login: user.username, device_response: "{}") }.to change { SecurityEvent.count }.by(1) diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 2a89159c070..41d263a46a4 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' describe SnippetsController do - describe 'GET #show' do - let(:user) { create(:user) } + let(:user) { create(:user) } + describe 'GET #show' do context 'when the personal snippet is private' do let(:personal_snippet) { create(:personal_snippet, :private, author: user) } @@ -230,4 +230,33 @@ describe SnippetsController do end end end + + context 'award emoji on snippets' do + let(:personal_snippet) { create(:personal_snippet, :public, author: user) } + let(:another_user) { create(:user) } + + before do + sign_in(another_user) + end + + describe 'POST #toggle_award_emoji' do + it "toggles the award emoji" do + expect do + post(:toggle_award_emoji, id: personal_snippet.to_param, name: "thumbsup") + end.to change { personal_snippet.award_emoji.count }.from(0).to(1) + + expect(response.status).to eq(200) + end + + it "removes the already awarded emoji" do + post(:toggle_award_emoji, id: personal_snippet.to_param, name: "thumbsup") + + expect do + post(:toggle_award_emoji, id: personal_snippet.to_param, name: "thumbsup") + end.to change { personal_snippet.award_emoji.count }.from(1).to(0) + + expect(response.status).to eq(200) + end + end + end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 54a2d3d9460..19a8b1fe524 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -73,8 +73,8 @@ describe UsersController do end context 'forked project' do - let!(:project) { create(:project) } - let!(:forked_project) { Projects::ForkService.new(project, user).execute } + let(:project) { create(:project) } + let(:forked_project) { Projects::ForkService.new(project, user).execute } before do sign_in(user) diff --git a/spec/factories/boards.rb b/spec/factories/boards.rb new file mode 100644 index 00000000000..ec46146d9b5 --- /dev/null +++ b/spec/factories/boards.rb @@ -0,0 +1,10 @@ +FactoryGirl.define do + factory :board do + project factory: :empty_project + + after(:create) do |board| + board.lists.create(list_type: :backlog) + board.lists.create(list_type: :done) + end + end +end diff --git a/spec/factories/broadcast_messages.rb b/spec/factories/broadcast_messages.rb index efe9803b1a7..c2fdf89213a 100644 --- a/spec/factories/broadcast_messages.rb +++ b/spec/factories/broadcast_messages.rb @@ -1,8 +1,8 @@ FactoryGirl.define do factory :broadcast_message do message "MyText" - starts_at Date.yesterday - ends_at Date.tomorrow + starts_at 1.day.ago + ends_at 1.day.from_now trait :expired do starts_at 5.days.ago diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 1b32d560b16..0c93bbdfe26 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -7,6 +7,7 @@ FactoryGirl.define do stage_idx 0 ref 'master' tag false + status 'pending' created_at 'Di 29. Okt 09:50:00 CET 2013' started_at 'Di 29. Okt 09:51:28 CET 2013' finished_at 'Di 29. Okt 09:53:28 CET 2013' @@ -45,6 +46,10 @@ FactoryGirl.define do status 'pending' end + trait :created do + status 'created' + end + trait :manual do status 'skipped' self.when 'manual' diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index a039bef6f3c..ac2a1ba5dff 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -1,24 +1,8 @@ -# == Schema Information -# -# Table name: commits -# -# id :integer not null, primary key -# project_id :integer -# ref :string(255) -# sha :string(255) -# before_sha :string(255) -# push_data :text -# created_at :datetime -# updated_at :datetime -# tag :boolean default(FALSE) -# yaml_errors :text -# committed_at :datetime -# gl_project_id :integer -# - FactoryGirl.define do factory :ci_empty_pipeline, class: Ci::Pipeline do + ref 'master' sha '97de212e80737a608d939f648d959671fb0a0142' + status 'pending' project factory: :empty_project diff --git a/spec/factories/ci/runner_projects.rb b/spec/factories/ci/runner_projects.rb index 83fccad679f..3372e5ab685 100644 --- a/spec/factories/ci/runner_projects.rb +++ b/spec/factories/ci/runner_projects.rb @@ -1,14 +1,3 @@ -# == Schema Information -# -# Table name: runner_projects -# -# id :integer not null, primary key -# runner_id :integer not null -# project_id :integer not null -# created_at :datetime -# updated_at :datetime -# - FactoryGirl.define do factory :ci_runner_project, class: Ci::RunnerProject do runner_id 1 diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index 5b645fab32e..e3b73e29987 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -1,22 +1,3 @@ -# == Schema Information -# -# Table name: runners -# -# id :integer not null, primary key -# token :string(255) -# created_at :datetime -# updated_at :datetime -# description :string(255) -# contacted_at :datetime -# active :boolean default(TRUE), not null -# is_shared :boolean default(FALSE) -# name :string(255) -# version :string(255) -# revision :string(255) -# platform :string(255) -# architecture :string(255) -# - FactoryGirl.define do factory :ci_runner, class: Ci::Runner do sequence :description do |n| @@ -30,5 +11,9 @@ FactoryGirl.define do trait :shared do is_shared true end + + trait :inactive do + active false + end end end diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb index 856a8e725eb..6653f0bb5c3 100644 --- a/spec/factories/ci/variables.rb +++ b/spec/factories/ci/variables.rb @@ -1,17 +1,3 @@ -# == Schema Information -# -# Table name: ci_variables -# -# id :integer not null, primary key -# project_id :integer not null -# key :string(255) -# value :text -# encrypted_value :text -# encrypted_value_salt :string(255) -# encrypted_value_iv :string(255) -# gl_project_id :integer -# - FactoryGirl.define do factory :ci_variable, class: Ci::Variable do sequence(:key) { |n| "VARIABLE_#{n}" } diff --git a/spec/factories/commit_statuses.rb b/spec/factories/commit_statuses.rb index 1e5c479616c..995f2080f10 100644 --- a/spec/factories/commit_statuses.rb +++ b/spec/factories/commit_statuses.rb @@ -7,6 +7,30 @@ FactoryGirl.define do started_at 'Tue, 26 Jan 2016 08:21:42 +0100' finished_at 'Tue, 26 Jan 2016 08:23:42 +0100' + trait :success do + status 'success' + end + + trait :failed do + status 'failed' + end + + trait :canceled do + status 'canceled' + end + + trait :running do + status 'running' + end + + trait :pending do + status 'pending' + end + + trait :created do + status 'created' + end + after(:build) do |build, evaluator| build.project = build.pipeline.project end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index 82591604fcb..6f24bf58d14 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -3,11 +3,12 @@ FactoryGirl.define do sha '97de212e80737a608d939f648d959671fb0a0142' ref 'master' tag false + project nil environment factory: :environment after(:build) do |deployment, evaluator| - deployment.project = deployment.environment.project + deployment.project ||= deployment.environment.project end end end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index 90788f30ac9..8820d527c61 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -1,10 +1,11 @@ FactoryGirl.define do factory :event do + project + author factory: :user + factory :closed_issue_event do - project action { Event::CLOSED } target factory: :closed_issue - author factory: :user end end end diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb index debb86d997f..795df5dfda9 100644 --- a/spec/factories/group_members.rb +++ b/spec/factories/group_members.rb @@ -1,20 +1,13 @@ -# == Schema Information -# -# Table name: group_members -# -# id :integer not null, primary key -# group_access :integer not null -# group_id :integer not null -# user_id :integer not null -# created_at :datetime -# updated_at :datetime -# notification_level :integer default(3), not null -# - FactoryGirl.define do factory :group_member do access_level { GroupMember::OWNER } group user + + trait(:guest) { access_level GroupMember::GUEST } + trait(:reporter) { access_level GroupMember::REPORTER } + trait(:developer) { access_level GroupMember::DEVELOPER } + trait(:master) { access_level GroupMember::MASTER } + trait(:owner) { access_level GroupMember::OWNER } end end diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 2c0a2dd94ca..2b4670be468 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -1,4 +1,8 @@ FactoryGirl.define do + sequence :issue_created_at do |n| + 4.hours.ago + ( 2 * n ).seconds + end + factory :issue do title author diff --git a/spec/factories/lists.rb b/spec/factories/lists.rb new file mode 100644 index 00000000000..9e3f06c682c --- /dev/null +++ b/spec/factories/lists.rb @@ -0,0 +1,20 @@ +FactoryGirl.define do + factory :list do + board + label + list_type :label + sequence(:position) + end + + factory :backlog_list, parent: :list do + list_type :backlog + label nil + position nil + end + + factory :done_list, parent: :list do + list_type :done + label nil + position nil + end +end diff --git a/spec/factories/milestones.rb b/spec/factories/milestones.rb index e9e85962fe4..84da71ed6dc 100644 --- a/spec/factories/milestones.rb +++ b/spec/factories/milestones.rb @@ -3,10 +3,15 @@ FactoryGirl.define do title project + trait :active do + state "active" + end + trait :closed do - state :closed + state "closed" end + factory :active_milestone, traits: [:active] factory :closed_milestone, traits: [:closed] end end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 83e38095feb..6919002dedc 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -28,6 +28,11 @@ FactoryGirl.define do diff_refs: noteable.diff_refs ) end + + trait :resolved do + resolved_at { Time.now } + resolved_by { create(:user) } + end end factory :diff_note_on_commit, traits: [:on_commit], class: DiffNote do diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 3195fb3ddcc..424ecc65759 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -5,5 +5,16 @@ FactoryGirl.define do trait :token do token { SecureRandom.hex(10) } end + + trait :all_events_enabled do + push_events true + merge_requests_events true + tag_push_events true + issues_events true + note_events true + build_events true + pipeline_events true + wiki_page_events true + end end end diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb index cf3659ba275..1ddb305a8af 100644 --- a/spec/factories/project_members.rb +++ b/spec/factories/project_members.rb @@ -4,24 +4,9 @@ FactoryGirl.define do project master - trait :guest do - access_level ProjectMember::GUEST - end - - trait :reporter do - access_level ProjectMember::REPORTER - end - - trait :developer do - access_level ProjectMember::DEVELOPER - end - - trait :master do - access_level ProjectMember::MASTER - end - - trait :owner do - access_level ProjectMember::OWNER - end + trait(:guest) { access_level ProjectMember::GUEST } + trait(:reporter) { access_level ProjectMember::REPORTER } + trait(:developer) { access_level ProjectMember::DEVELOPER } + trait(:master) { access_level ProjectMember::MASTER } end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index b682ced75ac..719ef17f57e 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -8,7 +8,9 @@ FactoryGirl.define do path { name.downcase.gsub(/\s/, '_') } namespace creator - snippets_enabled true + + # Behaves differently to nil due to cache_has_external_issue_tracker + has_external_issue_tracker false trait :public do visibility_level Gitlab::VisibilityLevel::PUBLIC @@ -27,6 +29,34 @@ FactoryGirl.define do project.create_repository end end + + trait :broken_repo do + after(:create) do |project| + project.create_repository + + FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.path_with_namespace}.git", 'refs')) + end + end + + # Nest Project Feature attributes + transient do + wiki_access_level ProjectFeature::ENABLED + builds_access_level ProjectFeature::ENABLED + snippets_access_level ProjectFeature::ENABLED + issues_access_level ProjectFeature::ENABLED + merge_requests_access_level ProjectFeature::ENABLED + end + + after(:create) do |project, evaluator| + project.project_feature. + update_attributes( + wiki_access_level: evaluator.wiki_access_level, + builds_access_level: evaluator.builds_access_level, + snippets_access_level: evaluator.snippets_access_level, + issues_access_level: evaluator.issues_access_level, + merge_requests_access_level: evaluator.merge_requests_access_level, + ) + end end # Project with empty repository @@ -37,6 +67,13 @@ FactoryGirl.define do empty_repo end + # Project with broken repository + # + # Project with an invalid repository state + factory :project_broken_repo, parent: :empty_project do + broken_repo + end + # Project with test repository # # Test repository source can be found at @@ -58,6 +95,8 @@ FactoryGirl.define do end factory :redmine_project, parent: :project do + has_external_issue_tracker true + after :create do |project| project.create_redmine_service( active: true, @@ -71,6 +110,8 @@ FactoryGirl.define do end factory :jira_project, parent: :project do + has_external_issue_tracker true + after :create do |project| project.create_jira_service( active: true, diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index 5575852c2d7..b2695e0482a 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -3,26 +3,26 @@ FactoryGirl.define do name project - after(:create) do |protected_branch| - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) + after(:build) do |protected_branch| + protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER) + protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER) end trait :developers_can_push do after(:create) do |protected_branch| - protected_branch.push_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) end end trait :developers_can_merge do after(:create) do |protected_branch| - protected_branch.merge_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) end end trait :no_one_can_push do after(:create) do |protected_branch| - protected_branch.push_access_level.update!(access_level: Gitlab::Access::NO_ACCESS) + protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS) end end end diff --git a/spec/factories/user_agent_details.rb b/spec/factories/user_agent_details.rb new file mode 100644 index 00000000000..9763cc0cf15 --- /dev/null +++ b/spec/factories/user_agent_details.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :user_agent_detail do + ip_address '127.0.0.1' + user_agent 'AppleWebKit/537.36' + association :subject, factory: :issue + end +end diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 675d9bd18b7..786e1456f5f 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -9,7 +9,7 @@ describe 'factories' do expect { entity }.not_to raise_error end - it 'should be valid', if: factory.build_class < ActiveRecord::Base do + it 'is valid', if: factory.build_class < ActiveRecord::Base do expect(entity).to be_valid end end diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb index 16baf7e9516..c1731e6414a 100644 --- a/spec/features/admin/admin_abuse_reports_spec.rb +++ b/spec/features/admin/admin_abuse_reports_spec.rb @@ -11,7 +11,7 @@ describe "Admin::AbuseReports", feature: true, js: true do end describe 'in the abuse report view' do - it "should present a link to the user's profile" do + it "presents a link to the user's profile" do visit admin_abuse_reports_path expect(page).to have_link user.name, href: user_path(user) @@ -19,7 +19,7 @@ describe "Admin::AbuseReports", feature: true, js: true do end describe 'in the profile page of the user' do - it 'should show a link to the admin view of the user' do + it 'shows a link to the admin view of the user' do visit user_path(user) expect(page).to have_link '', href: admin_user_path(user) diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb index 5b1c0460274..66044b44495 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -45,7 +45,6 @@ feature 'Admin disables Git access protocol', feature: true do expect(page).to have_content("git clone #{project.ssh_url_to_repo}") expect(page).to have_selector('#clone-dropdown') end - end def visit_project diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index 7964951ae99..b3ce72b1452 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -9,7 +9,7 @@ describe "Admin::Hooks", feature: true do end describe "GET /admin/hooks" do - it "should be ok" do + it "is ok" do visit admin_root_path page.within ".layout-nav" do @@ -19,7 +19,7 @@ describe "Admin::Hooks", feature: true do expect(current_path).to eq(admin_hooks_path) end - it "should have hooks list" do + it "has hooks list" do visit admin_hooks_path expect(page).to have_content(@system_hook.url) end @@ -33,7 +33,7 @@ describe "Admin::Hooks", feature: true do expect { click_button "Add System Hook" }.to change(SystemHook, :count).by(1) end - it "should open new hook popup" do + it "opens new hook popup" do expect(current_path).to eq(admin_hooks_path) expect(page).to have_content(@url) end diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index 101d955d693..30ded9202a4 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -11,11 +11,11 @@ describe "Admin::Projects", feature: true do visit admin_namespaces_projects_path end - it "should be ok" do + it "is ok" do expect(current_path).to eq(admin_namespaces_projects_path) end - it "should have projects list" do + it "has projects list" do expect(page).to have_content(@project.name) end end @@ -26,7 +26,7 @@ describe "Admin::Projects", feature: true do click_link "#{@project.name}" end - it "should have project info" do + it "has project info" do expect(page).to have_content(@project.path) expect(page).to have_content(@project.name) end diff --git a/spec/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb index f4e5c26b519..1df972843e2 100644 --- a/spec/features/admin/admin_system_info_spec.rb +++ b/spec/features/admin/admin_system_info_spec.rb @@ -6,12 +6,49 @@ describe 'Admin System Info' do end describe 'GET /admin/system_info' do - it 'shows system info page' do - visit admin_system_info_path + let(:cpu) { double(:cpu, length: 2) } + let(:memory) { double(:memory, active_bytes: 4294967296, total_bytes: 17179869184) } - expect(page).to have_content 'CPU' - expect(page).to have_content 'Memory' - expect(page).to have_content 'Disks' + context 'when all info is available' do + before do + allow(Vmstat).to receive(:cpu).and_return(cpu) + allow(Vmstat).to receive(:memory).and_return(memory) + visit admin_system_info_path + end + + it 'shows system info page' do + expect(page).to have_content 'CPU 2 cores' + expect(page).to have_content 'Memory 4 GB / 16 GB' + expect(page).to have_content 'Disks' + end + end + + context 'when CPU info is not available' do + before do + allow(Vmstat).to receive(:cpu).and_raise(Errno::ENOENT) + allow(Vmstat).to receive(:memory).and_return(memory) + visit admin_system_info_path + end + + it 'shows system info page with no CPU info' do + expect(page).to have_content 'CPU Unable to collect CPU info' + expect(page).to have_content 'Memory 4 GB / 16 GB' + expect(page).to have_content 'Disks' + end + end + + context 'when memory info is not available' do + before do + allow(Vmstat).to receive(:cpu).and_return(cpu) + allow(Vmstat).to receive(:memory).and_raise(Errno::ENOENT) + visit admin_system_info_path + end + + it 'shows system info page with no CPU info' do + expect(page).to have_content 'CPU 2 cores' + expect(page).to have_content 'Memory Unable to collect memory info' + expect(page).to have_content 'Disks' + end end end end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 767504df251..cb3191dfdde 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -8,11 +8,11 @@ describe "Admin::Users", feature: true do visit admin_users_path end - it "should be ok" do + it "is ok" do expect(current_path).to eq(admin_users_path) end - it "should have users list" do + it "has users list" do expect(page).to have_content(@user.email) expect(page).to have_content(@user.name) end @@ -66,11 +66,11 @@ describe "Admin::Users", feature: true do fill_in "user_email", with: "bigbang@mail.com" end - it "should create new user" do + it "creates new user" do expect { click_button "Create user" }.to change {User.count}.by(1) end - it "should apply defaults to user" do + it "applies defaults to user" do click_button "Create user" user = User.find_by(username: 'bang') expect(user.projects_limit). @@ -79,20 +79,20 @@ describe "Admin::Users", feature: true do to eq(Gitlab.config.gitlab.default_can_create_group) end - it "should create user with valid data" do + it "creates user with valid data" do click_button "Create user" user = User.find_by(username: 'bang') expect(user.name).to eq('Big Bang') expect(user.email).to eq('bigbang@mail.com') end - it "should call send mail" do + it "calls send mail" do expect_any_instance_of(NotificationService).to receive(:new_user) click_button "Create user" end - it "should send valid email to user with email & password" do + it "sends valid email to user with email & password" do perform_enqueued_jobs do click_button "Create user" end @@ -106,7 +106,7 @@ describe "Admin::Users", feature: true do end describe "GET /admin/users/:id" do - it "should have user info" do + it "has user info" do visit admin_users_path click_link @user.name @@ -123,13 +123,13 @@ describe "Admin::Users", feature: true do expect(page).to have_content('Impersonate') end - it 'should not show impersonate button for admin itself' do + it 'does not show impersonate button for admin itself' do visit admin_user_path(@user) expect(page).not_to have_content('Impersonate') end - it 'should not show impersonate button for blocked user' do + it 'does not show impersonate button for blocked user' do another_user.block visit admin_user_path(another_user) @@ -153,7 +153,7 @@ describe "Admin::Users", feature: true do expect(icon).not_to eql nil end - it 'can log out of impersonated user back to original user' 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) @@ -197,7 +197,7 @@ describe "Admin::Users", feature: true do click_link "edit_user_#{@simple_user.id}" end - it "should have user edit page" do + it "has user edit page" do expect(page).to have_content('Name') expect(page).to have_content('Password') end @@ -212,12 +212,12 @@ describe "Admin::Users", feature: true do click_button "Save changes" end - it "should show 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 "should change user entry" do + it "changes user entry" do @simple_user.reload expect(@simple_user.name).to eq('Big Bang') expect(@simple_user.is_admin?).to be_truthy diff --git a/spec/features/atom/dashboard_spec.rb b/spec/features/atom/dashboard_spec.rb index f81a3c117ff..746df36bb25 100644 --- a/spec/features/atom/dashboard_spec.rb +++ b/spec/features/atom/dashboard_spec.rb @@ -5,7 +5,7 @@ describe "Dashboard Feed", feature: true do let!(:user) { create(:user, name: "Jonh") } context "projects atom feed via private token" do - it "should render projects atom feed" do + it "renders projects atom feed" do visit dashboard_projects_path(:atom, private_token: user.private_token) expect(body).to have_selector('feed title') end @@ -23,11 +23,11 @@ describe "Dashboard Feed", feature: true do visit dashboard_projects_path(:atom, private_token: user.private_token) end - it "should have issue opened event" do + it "has issue opened event" do expect(body).to have_content("#{user.name} opened issue ##{issue.iid}") end - it "should have issue comment event" do + it "has issue comment event" do expect(body). to have_content("#{user.name} commented on issue ##{issue.iid}") end diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index baa7814e96a..09c140868fb 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -9,7 +9,7 @@ describe 'Issues Feed', feature: true do before { project.team << [user, :developer] } context 'when authenticated' do - it 'should render atom feed' do + it 'renders atom feed' do login_with user visit namespace_project_issues_path(project.namespace, project, :atom) @@ -22,7 +22,7 @@ describe 'Issues Feed', feature: true do end context 'when authenticated via private token' do - it 'should render atom feed' do + it 'renders atom feed' do visit namespace_project_issues_path(project.namespace, project, :atom, private_token: user.private_token) diff --git a/spec/features/atom/users_spec.rb b/spec/features/atom/users_spec.rb index 91704377a07..a8833194421 100644 --- a/spec/features/atom/users_spec.rb +++ b/spec/features/atom/users_spec.rb @@ -5,7 +5,7 @@ describe "User Feed", feature: true do let!(:user) { create(:user) } context 'user atom feed via private token' do - it "should render user atom feed" do + it "renders user atom feed" do visit user_path(user, :atom, private_token: user.private_token) expect(body).to have_selector('feed title') end @@ -43,24 +43,24 @@ describe "User Feed", feature: true do visit user_path(user, :atom, private_token: user.private_token) end - it 'should have issue opened event' do + it 'has issue opened event' do expect(body).to have_content("#{safe_name} opened issue ##{issue.iid}") end - it 'should have issue comment event' do + it 'has issue comment event' do expect(body). to have_content("#{safe_name} commented on issue ##{issue.iid}") end - it 'should have XHTML summaries in issue descriptions' do + it 'has XHTML summaries in issue descriptions' do expect(body).to match /we have a bug!<\/p>\n\n<hr ?\/>\n\n<p>I guess/ end - it 'should have XHTML summaries in notes' do + it 'has XHTML summaries in notes' do expect(body).to match /Bug confirmed <img[^>]*\/>/ end - it 'should have XHTML summaries in merge request descriptions' do + it 'has XHTML summaries in merge request descriptions' do expect(body).to match /Here is the fix: <\/p><div[^>]*><a[^>]*><img[^>]*\/><\/a><\/div>/ end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb new file mode 100644 index 00000000000..0fb1608a0a3 --- /dev/null +++ b/spec/features/boards/boards_spec.rb @@ -0,0 +1,667 @@ +require 'rails_helper' + +describe 'Issue Boards', feature: true, js: true do + include WaitForAjax + include WaitForVueResource + + let(:project) { create(:empty_project, :public) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + let!(:user2) { create(:user) } + + before do + project.team << [user, :master] + project.team << [user2, :master] + + login_as(user) + end + + context 'no lists' do + before do + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + expect(page).to have_selector('.board', count: 3) + end + + it 'shows blank state' do + expect(page).to have_content('Welcome to your Issue Board!') + end + + it 'hides the blank state when clicking nevermind button' do + page.within(find('.board-blank-state')) do + click_button("Nevermind, I'll use my own") + end + expect(page).to have_selector('.board', count: 2) + end + + it 'creates default lists' do + lists = ['Backlog', 'To Do', 'Doing', 'Done'] + + page.within(find('.board-blank-state')) do + click_button('Add default lists') + end + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 4) + + page.all('.board').each_with_index do |list, i| + expect(list.find('.board-title')).to have_content(lists[i]) + end + end + end + + context 'with lists' do + let(:milestone) { create(:milestone, project: project) } + + let(:planning) { create(:label, project: project, name: 'Planning') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + let(:bug) { create(:label, project: project, name: 'Bug') } + let!(:backlog) { create(:label, project: project, name: 'Backlog') } + let!(:done) { create(:label, project: project, name: 'Done') } + let!(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') } + + let!(:list1) { create(:list, board: board, label: planning, position: 0) } + let!(:list2) { create(:list, board: board, label: development, position: 1) } + + let!(:confidential_issue) { create(:issue, :confidential, project: project, author: user) } + let!(:issue1) { create(:issue, project: project, assignee: user) } + let!(:issue2) { create(:issue, project: project, author: user2) } + let!(:issue3) { create(:issue, project: project) } + let!(:issue4) { create(:issue, project: project) } + let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) } + let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) } + let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) } + let!(:issue8) { create(:closed_issue, project: project) } + let!(:issue9) { create(:labeled_issue, project: project, labels: [testing, bug, accepting]) } + + before do + visit namespace_project_board_path(project.namespace, project, board) + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 4) + expect(find('.board:nth-child(1)')).to have_selector('.card') + expect(find('.board:nth-child(2)')).to have_selector('.card') + expect(find('.board:nth-child(3)')).to have_selector('.card') + expect(find('.board:nth-child(4)')).to have_selector('.card') + end + + it 'shows lists' do + expect(page).to have_selector('.board', count: 4) + end + + it 'shows issues in lists' do + wait_for_board_cards(2, 2) + wait_for_board_cards(3, 2) + end + + it 'shows confidential issues with icon' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.confidential-icon', count: 1) + end + end + + it 'search backlog list' do + page.within('#js-boards-seach') do + find('.form-control').set(issue1.title) + end + + wait_for_vue_resource + + expect(find('.board:nth-child(1)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 0) + end + + it 'search done list' do + page.within('#js-boards-seach') do + find('.form-control').set(issue8.title) + end + + wait_for_vue_resource + + expect(find('.board:nth-child(1)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 1) + end + + it 'search list' do + page.within('#js-boards-seach') do + find('.form-control').set(issue5.title) + end + + wait_for_vue_resource + + expect(find('.board:nth-child(1)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(2)')).to have_selector('.card', count: 1) + expect(find('.board:nth-child(3)')).to have_selector('.card', count: 0) + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 0) + end + + it 'allows user to delete board' do + page.within(find('.board:nth-child(2)')) do + find('.board-delete').click + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + end + + it 'removes checkmark in new list dropdown after deleting' do + click_button 'Create new list' + wait_for_ajax + + page.within(find('.board:nth-child(2)')) do + find('.board-delete').click + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + expect(find(".js-board-list-#{planning.id}", visible: false)).not_to have_css('.is-active') + end + + it 'infinite scrolls list' do + 50.times do + create(:issue, project: project) + end + + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('56') + expect(page).to have_selector('.card', count: 20) + expect(page).to have_content('Showing 20 of 56 issues') + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + wait_for_vue_resource + + expect(page).to have_selector('.card', count: 40) + expect(page).to have_content('Showing 40 of 56 issues') + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + wait_for_vue_resource + + expect(page).to have_selector('.card', count: 56) + expect(page).to have_content('Showing all issues') + end + end + + context 'backlog' do + it 'shows issues in backlog with no labels' do + wait_for_board_cards(1, 6) + end + + it 'moves issue from backlog into list' do + drag_to(list_to_index: 1) + + wait_for_vue_resource + wait_for_board_cards(1, 5) + wait_for_board_cards(2, 3) + end + end + + context 'done' do + it 'shows list of done issues' do + wait_for_board_cards(4, 1) + wait_for_ajax + end + + it 'moves issue to done' do + drag_to(list_from_index: 0, list_to_index: 3) + + wait_for_board_cards(1, 5) + wait_for_board_cards(2, 2) + wait_for_board_cards(3, 2) + wait_for_board_cards(4, 2) + + expect(find('.board:nth-child(1)')).not_to have_content(issue9.title) + expect(find('.board:nth-child(4)')).to have_selector('.card', count: 2) + expect(find('.board:nth-child(4)')).to have_content(issue9.title) + expect(find('.board:nth-child(4)')).not_to have_content(planning.title) + end + + it 'removes all of the same issue to done' do + drag_to(list_from_index: 1, list_to_index: 3) + + wait_for_board_cards(1, 6) + wait_for_board_cards(2, 1) + wait_for_board_cards(3, 1) + wait_for_board_cards(4, 2) + + expect(find('.board:nth-child(2)')).not_to have_content(issue6.title) + expect(find('.board:nth-child(4)')).to have_content(issue6.title) + expect(find('.board:nth-child(4)')).not_to have_content(planning.title) + end + end + + context 'lists' do + it 'changes position of list' do + drag_to(list_from_index: 1, list_to_index: 2, selector: '.board-header') + + wait_for_board_cards(1, 6) + wait_for_board_cards(2, 2) + wait_for_board_cards(3, 2) + wait_for_board_cards(4, 1) + + expect(find('.board:nth-child(2)')).to have_content(development.title) + expect(find('.board:nth-child(2)')).to have_content(planning.title) + end + + it 'issue moves between lists' do + drag_to(list_from_index: 1, card_index: 1, list_to_index: 2) + + wait_for_board_cards(1, 6) + wait_for_board_cards(2, 1) + wait_for_board_cards(3, 3) + wait_for_board_cards(4, 1) + + expect(find('.board:nth-child(3)')).to have_content(issue6.title) + expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title) + end + + it 'issue moves between lists' do + drag_to(list_from_index: 2, list_to_index: 1) + + wait_for_board_cards(1, 6) + wait_for_board_cards(2, 3) + wait_for_board_cards(3, 1) + wait_for_board_cards(4, 1) + + expect(find('.board:nth-child(2)')).to have_content(issue7.title) + expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title) + end + + it 'issue moves from done' do + drag_to(list_from_index: 3, list_to_index: 1) + + expect(find('.board:nth-child(2)')).to have_content(issue8.title) + + wait_for_board_cards(1, 6) + wait_for_board_cards(2, 3) + wait_for_board_cards(3, 2) + wait_for_board_cards(4, 0) + end + + context 'issue card' do + it 'shows assignee' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.avatar', count: 1) + end + end + end + + context 'new list' do + it 'shows all labels in new list dropdown' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + expect(page).to have_content(planning.title) + expect(page).to have_content(development.title) + expect(page).to have_content(testing.title) + end + end + + it 'creates new list for label' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link testing.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'creates new list for Backlog label' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link backlog.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'creates new list for Done label' do + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link done.title + end + + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 5) + end + + it 'moves issues from backlog into new list' do + wait_for_board_cards(1, 6) + + click_button 'Create new list' + wait_for_ajax + + page.within('.dropdown-menu-issues-board-new') do + click_link testing.title + end + + wait_for_vue_resource + + wait_for_board_cards(1, 5) + end + end + end + + context 'filtering' do + it 'filters by author' do + page.within '.issues-filters' do + click_button('Author') + wait_for_ajax + + page.within '.dropdown-menu-author' do + click_link(user2.name) + end + wait_for_vue_resource + + expect(find('.js-author-search')).to have_content(user2.name) + end + + wait_for_vue_resource + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..4)) + end + + it 'filters by assignee' do + page.within '.issues-filters' do + click_button('Assignee') + wait_for_ajax + + page.within '.dropdown-menu-assignee' do + click_link(user.name) + end + wait_for_vue_resource + + expect(find('.js-assignee-search')).to have_content(user.name) + end + + wait_for_vue_resource + + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..4)) + end + + it 'filters by milestone' do + page.within '.issues-filters' do + click_button('Milestone') + wait_for_ajax + + page.within '.milestone-filter' do + click_link(milestone.title) + end + wait_for_vue_resource + + expect(find('.js-milestone-select')).to have_content(milestone.title) + end + + wait_for_vue_resource + wait_for_board_cards(1, 0) + wait_for_board_cards(2, 1) + wait_for_board_cards(3, 0) + wait_for_board_cards(4, 0) + end + + it 'filters by label' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..4)) + end + + it 'filters by label with space after reload' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link(accepting.title) + wait_for_vue_resource(spinner: false) + find('.dropdown-menu-close').click + end + end + + # Test after reload + page.evaluate_script 'window.location.reload()' + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('1') + expect(page).to have_selector('.card', count: 1) + end + + page.within(find('.board:nth-child(2)')) do + expect(page.find('.board-header')).to have_content('0') + expect(page).to have_selector('.card', count: 0) + end + end + + it 'removes filtered labels' do + wait_for_vue_resource + + page.within '.labels-filter' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + end + + expect(page).to have_css('input[name="label_name[]"]', visible: false) + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource(spinner: false) + end + + expect(page).not_to have_css('input[name="label_name[]"]', visible: false) + end + end + + it 'infinite scrolls list with label filter' do + 50.times do + create(:labeled_issue, project: project, labels: [testing]) + end + + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link(testing.title) + wait_for_vue_resource + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + page.within(find('.board', match: :first)) do + expect(page.find('.board-header')).to have_content('51') + expect(page).to have_selector('.card', count: 20) + expect(page).to have_content('Showing 20 of 51 issues') + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + + expect(page).to have_selector('.card', count: 40) + expect(page).to have_content('Showing 40 of 51 issues') + + evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight") + + expect(page).to have_selector('.card', count: 51) + expect(page).to have_content('Showing all issues') + end + end + + it 'filters by multiple labels' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within(find('.dropdown-menu-labels')) do + click_link(testing.title) + wait_for_vue_resource + click_link(bug.title) + wait_for_vue_resource + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..4)) + end + + it 'filters by no label' do + page.within '.issues-filters' do + click_button('Label') + wait_for_ajax + + page.within '.dropdown-menu-labels' do + click_link("No Label") + wait_for_vue_resource + find('.dropdown-menu-close').click + end + end + + wait_for_vue_resource + + wait_for_board_cards(1, 5) + wait_for_board_cards(2, 0) + wait_for_board_cards(3, 0) + wait_for_board_cards(4, 1) + end + + it 'filters by clicking label button on issue' do + page.within(find('.board', match: :first)) do + expect(page).to have_selector('.card', count: 6) + expect(find('.card', match: :first)).to have_content(bug.title) + click_button(bug.title) + wait_for_vue_resource + end + + wait_for_vue_resource + + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..4)) + + page.within('.labels-filter') do + expect(find('.dropdown-toggle-text')).to have_content(bug.title) + end + end + + it 'removes label filter by clicking label button on issue' do + page.within(find('.board', match: :first)) do + page.within(find('.card', match: :first)) do + click_button(bug.title) + end + wait_for_vue_resource + + expect(page).to have_selector('.card', count: 1) + end + + wait_for_vue_resource + + page.within('.labels-filter') do + expect(find('.dropdown-toggle-text')).to have_content(bug.title) + end + end + end + end + + context 'keyboard shortcuts' do + before do + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + end + + it 'allows user to use keyboard shortcuts' do + find('.boards-list').native.send_keys('i') + expect(page).to have_content('New Issue') + end + end + + context 'signed out user' do + before do + logout + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + end + + it 'does not show create new list' do + expect(page).not_to have_selector('.js-new-board-list') + end + end + + context 'as guest user' do + let(:user_guest) { create(:user) } + + before do + project.team << [user_guest, :guest] + logout + login_as(user_guest) + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + end + + it 'does not show create new list' do + expect(page).not_to have_selector('.js-new-board-list') + end + end + + def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list') + evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});") + + Timeout.timeout(Capybara.default_max_wait_time) do + loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero? + end + + wait_for_vue_resource + end + + def wait_for_board_cards(board_number, expected_cards) + page.within(find(".board:nth-child(#{board_number})")) do + expect(page.find('.board-header')).to have_content(expected_cards.to_s) + expect(page).to have_selector('.card', count: expected_cards) + end + end + + def wait_for_empty_boards(board_numbers) + board_numbers.each do |board| + wait_for_board_cards(board, 0) + end + end +end diff --git a/spec/features/boards/keyboard_shortcut_spec.rb b/spec/features/boards/keyboard_shortcut_spec.rb new file mode 100644 index 00000000000..a5fc766401f --- /dev/null +++ b/spec/features/boards/keyboard_shortcut_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe 'Issue Boards shortcut', feature: true, js: true do + include WaitForVueResource + + let(:project) { create(:empty_project) } + + before do + create(:board, project: project) + + login_as :admin + + visit namespace_project_path(project.namespace, project) + end + + it 'takes user to issue board index' do + find('body').native.send_keys('gl') + expect(page).to have_selector('.boards-list') + + wait_for_vue_resource + end +end diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb new file mode 100644 index 00000000000..67d6da5f39a --- /dev/null +++ b/spec/features/boards/new_issue_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +describe 'Issue Boards new issue', feature: true, js: true do + include WaitForAjax + include WaitForVueResource + + let(:project) { create(:empty_project, :public) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + + context 'authorized user' do + before do + project.team << [user, :master] + + login_as(user) + + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + end + + it 'displays new issue button' do + expect(page).to have_selector('.board-issue-count-holder .btn', count: 1) + end + + it 'does not display new issue button in done list' do + page.within('.board:nth-child(3)') do + expect(page).not_to have_selector('.board-issue-count-holder .btn') + end + end + + it 'shows form when clicking button' do + page.within(first('.board')) do + find('.board-issue-count-holder .btn').click + + expect(page).to have_selector('.board-new-issue-form') + end + end + + it 'hides form when clicking cancel' do + page.within(first('.board')) do + find('.board-issue-count-holder .btn').click + + expect(page).to have_selector('.board-new-issue-form') + + click_button 'Cancel' + + expect(page).to have_selector('.board-new-issue-form', visible: false) + end + end + + it 'creates new issue' do + page.within(first('.board')) do + find('.board-issue-count-holder .btn').click + end + + page.within(first('.board-new-issue-form')) do + find('.form-control').set('bug') + click_button 'Submit issue' + end + + wait_for_vue_resource + + page.within(first('.board .board-issue-count')) do + expect(page).to have_content('1') + end + end + end + + context 'unauthorized user' do + before do + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + end + + it 'does not display new issue button' do + expect(page).to have_selector('.board-issue-count-holder .btn', count: 0) + end + end +end diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb new file mode 100644 index 00000000000..7fa0c95cae2 --- /dev/null +++ b/spec/features/calendar_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' + +feature 'Contributions Calendar', js: true, feature: true do + include WaitForAjax + + let(:contributed_project) { create(:project, :public) } + + # Ex/ Sunday Jan 1, 2016 + date_format = '%A %b %-d, %Y' + + issue_title = 'Bug in old browser' + issue_params = { title: issue_title } + + def get_cell_color_selector(contributions) + contribution_cell = '.user-contrib-cell' + activity_colors = Array['#ededed', '#acd5f2', '#7fa8c9', '#527ba0', '#254e77'] + activity_colors_index = 0 + + if contributions > 0 && contributions < 10 + activity_colors_index = 1 + elsif contributions >= 10 && contributions < 20 + activity_colors_index = 2 + elsif contributions >= 20 && contributions < 30 + activity_colors_index = 3 + elsif contributions >= 30 + activity_colors_index = 4 + end + + "#{contribution_cell}[fill='#{activity_colors[activity_colors_index]}']" + end + + def get_cell_date_selector(contributions, date) + contribution_text = 'No contributions' + + if contributions === 1 + contribution_text = '1 contribution' + elsif contributions > 1 + contribution_text = "#{contributions} contributions" + end + + "#{get_cell_color_selector(contributions)}[data-original-title='#{contribution_text}<br />#{date}']" + end + + def push_code_contribution + push_params = { + project: contributed_project, + action: Event::PUSHED, + author_id: @user.id, + data: { commit_count: 3 } + } + + Event.create(push_params) + end + + before do + login_as :user + visit @user.username + wait_for_ajax + end + + it 'displays calendar', js: true do + expect(page).to have_css('.js-contrib-calendar') + end + + describe '1 calendar activity' do + before do + Issues::CreateService.new(contributed_project, @user, issue_params).execute + visit @user.username + wait_for_ajax + end + + it 'displays calendar activity log', js: true do + expect(find('.content_list .event-note')).to have_content issue_title + end + + it 'displays calendar activity square color for 1 contribution', js: true do + expect(page).to have_selector(get_cell_color_selector(1), count: 1) + end + + it 'displays calendar activity square on the correct date', js: true do + today = Date.today.strftime(date_format) + expect(page).to have_selector(get_cell_date_selector(1, today), count: 1) + end + end + + describe '10 calendar activities' do + before do + (0..9).each do |i| + push_code_contribution() + end + + visit @user.username + wait_for_ajax + end + + it 'displays calendar activity square color for 10 contributions', js: true do + expect(page).to have_selector(get_cell_color_selector(10), count: 1) + end + + it 'displays calendar activity square on the correct date', js: true do + today = Date.today.strftime(date_format) + expect(page).to have_selector(get_cell_date_selector(10, today), count: 1) + end + end + + describe 'calendar activity on two days' do + before do + push_code_contribution() + + Timecop.freeze(Date.yesterday) + Issues::CreateService.new(contributed_project, @user, issue_params).execute + Timecop.return + + visit @user.username + wait_for_ajax + end + + it 'displays calendar activity squares for both days', js: true do + expect(page).to have_selector(get_cell_color_selector(1), count: 2) + end + + it 'displays calendar activity square for yesterday', js: true do + yesterday = Date.yesterday.strftime(date_format) + expect(page).to have_selector(get_cell_date_selector(1, yesterday), count: 1) + end + + it 'displays calendar activity square for today', js: true do + today = Date.today.strftime(date_format) + expect(page).to have_selector(get_cell_date_selector(1, today), count: 1) + end + end +end diff --git a/spec/features/ci_lint_spec.rb b/spec/features/ci_lint_spec.rb index 30e29d9d552..81077f4b005 100644 --- a/spec/features/ci_lint_spec.rb +++ b/spec/features/ci_lint_spec.rb @@ -17,7 +17,7 @@ describe 'CI Lint' do File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) end - it 'Yaml parsing' do + it 'parses Yaml' do within "table" do expect(page).to have_content('Job - rspec') expect(page).to have_content('Job - spinach') diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 45e1a157a1f..5910803df51 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -52,7 +52,7 @@ describe 'Commits' do visit namespace_project_commits_path(project.namespace, project, :master) end - it 'should show build status' do + it 'shows build status' do page.within("//li[@id='commit-#{pipeline.short_sha}']") do expect(page).to have_css(".ci-status-link") end diff --git a/spec/features/compare_spec.rb b/spec/features/compare_spec.rb index c62556948e0..33dfd0d5b62 100644 --- a/spec/features/compare_spec.rb +++ b/spec/features/compare_spec.rb @@ -11,16 +11,17 @@ describe "Compare", js: true do end describe "branches" do - it "should pre-populate fields" do - expect(page.find_field("from").value).to eq("master") + it "pre-populates fields" do + expect(find(".js-compare-from-dropdown .dropdown-toggle-text")).to have_content("master") + expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("master") end - it "should compare branches" do - fill_in "from", with: "fea" - find("#from").click + it "compares branches" do + select_using_dropdown "from", "feature" + expect(find(".js-compare-from-dropdown .dropdown-toggle-text")).to have_content("feature") - click_link "feature" - expect(page.find_field("from").value).to eq("feature") + select_using_dropdown "to", "binary-encoding" + expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("binary-encoding") click_button "Compare" expect(page).to have_content "Commits" @@ -28,15 +29,22 @@ describe "Compare", js: true do end describe "tags" do - it "should compare tags" do - fill_in "from", with: "v1.0" - find("#from").click + it "compares tags" do + select_using_dropdown "from", "v1.0.0" + expect(find(".js-compare-from-dropdown .dropdown-toggle-text")).to have_content("v1.0.0") - click_link "v1.0.0" - expect(page.find_field("from").value).to eq("v1.0.0") + select_using_dropdown "to", "v1.1.0" + expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("v1.1.0") click_button "Compare" expect(page).to have_content "Commits" end end + + def select_using_dropdown(dropdown_type, selection) + dropdown = find(".js-compare-#{dropdown_type}-dropdown") + dropdown.find(".compare-dropdown-toggle").click + dropdown.fill_in("Filter by branch/tag", with: selection) + click_link selection + end end diff --git a/spec/features/dashboard/label_filter_spec.rb b/spec/features/dashboard/label_filter_spec.rb index 24e83d44010..4cff12de854 100644 --- a/spec/features/dashboard/label_filter_spec.rb +++ b/spec/features/dashboard/label_filter_spec.rb @@ -16,7 +16,7 @@ describe 'Dashboard > label filter', feature: true, js: true do end context 'duplicate labels' do - it 'should remove duplicate labels' do + it 'removes duplicate labels' do page.within('.labels-filter') do click_button 'Label' end diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb new file mode 100644 index 00000000000..62937688c22 --- /dev/null +++ b/spec/features/dashboard/snippets_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe 'Dashboard snippets', feature: true do + context 'when the project has snippets' do + let(:project) { create(:empty_project, :public) } + let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) } + before do + allow(Snippet).to receive(:default_per_page).and_return(1) + login_as(project.owner) + visit dashboard_snippets_path + end + + it_behaves_like 'paginated snippets' + end +end diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index 39805da9d0b..9b54b5301e5 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -16,29 +16,32 @@ describe "Dashboard Issues filtering", feature: true, js: true do visit_issues end - it 'should show all issues with no milestone' do + it 'shows all issues with no milestone' do show_milestone_dropdown click_link 'No Milestone' + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) end - it 'should show all issues with any milestone' do + it 'shows all issues with any milestone' do show_milestone_dropdown click_link 'Any Milestone' + expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) expect(page).to have_selector('.issue', count: 2) end - it 'should show all issues with the selected milestone' do + it 'shows all issues with the selected milestone' do show_milestone_dropdown page.within '.dropdown-content' do click_link milestone.title end + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) end end diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb index fcd41b38413..68ea4eeae31 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/environments_spec.rb @@ -44,6 +44,10 @@ feature 'Environments', feature: true do scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) end + + scenario 'does show deployment internal id' do + expect(page).to have_content(deployment.iid) + end context 'with build and manual actions' do given(:pipeline) { create(:ci_pipeline, project: project) } @@ -61,6 +65,20 @@ feature 'Environments', feature: true do expect(page).to have_content(manual.name) expect(manual.reload).to be_pending end + + scenario 'does show build name and id' do + expect(page).to have_link("#{build.name} (##{build.id})") + end + + context 'with external_url' do + given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:deployment) { create(:deployment, environment: environment, deployable: build) } + + scenario 'does show an external link button' do + expect(page).to have_link(nil, href: environment.external_url) + end + end end end end @@ -122,6 +140,16 @@ feature 'Environments', feature: true do expect(page).to have_content(manual.name) expect(manual.reload).to be_pending end + + context 'with external_url' do + given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:deployment) { create(:deployment, environment: environment, deployable: build) } + + scenario 'does show an external link button' do + expect(page).to have_link(nil, href: environment.external_url) + end + end end end end @@ -150,7 +178,7 @@ feature 'Environments', feature: true do context 'for invalid name' do before do - fill_in('Name', with: 'name with spaces') + fill_in('Name', with: 'name,with,commas') click_on 'Save' end diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb index 688f68d3cff..6c938bdead8 100644 --- a/spec/features/expand_collapse_diffs_spec.rb +++ b/spec/features/expand_collapse_diffs_spec.rb @@ -68,7 +68,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do context 'expanding a diff for a renamed file' do before do - large_diff_renamed.find('.nothing-here-block').click + large_diff_renamed.find('.click-to-expand').click wait_for_ajax end @@ -87,7 +87,10 @@ feature 'Expand and collapse diffs', js: true, feature: true do context 'expanding a large diff' do before do - click_link('large_diff.md') + # Wait for diffs + find('.file-title', match: :first) + # Click `large_diff.md` title + all('.file-title')[1].click wait_for_ajax end @@ -128,7 +131,10 @@ feature 'Expand and collapse diffs', js: true, feature: true do context 'expanding the diff' do before do - click_link('large_diff.md') + # Wait for diffs + find('.file-title', match: :first) + # Click `large_diff.md` title + all('.file-title')[1].click wait_for_ajax end @@ -146,7 +152,12 @@ feature 'Expand and collapse diffs', js: true, feature: true do end context 'collapsing an expanded diff' do - before { click_link('small_diff.md') } + before do + # Wait for diffs + find('.file-title', match: :first) + # Click `small_diff.md` title + all('.file-title')[3].click + end it 'hides the diff content' do expect(small_diff).not_to have_selector('.code') @@ -154,7 +165,12 @@ feature 'Expand and collapse diffs', js: true, feature: true do end context 're-expanding the same diff' do - before { click_link('small_diff.md') } + before do + # Wait for diffs + find('.file-title', match: :first) + # Click `small_diff.md` title + all('.file-title')[3].click + end it 'shows the diff content' do expect(small_diff).to have_selector('.code') @@ -211,6 +227,13 @@ feature 'Expand and collapse diffs', js: true, feature: true do context 'expanding all diffs' do before do click_link('Expand all') + + # Wait for elements to appear to ensure full page reload + expect(page).to have_content('This diff was suppressed by a .gitattributes entry') + expect(page).to have_content('This diff could not be displayed because it is too large.') + expect(page).to have_content('too_large_image.jpg') + find('.note-textarea') + wait_for_ajax execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });') end @@ -224,7 +247,12 @@ feature 'Expand and collapse diffs', js: true, feature: true do end context 'collapsing an expanded diff' do - before { click_link('small_diff.md') } + before do + # Wait for diffs + find('.file-title', match: :first) + # Click `small_diff.md` title + all('.file-title')[3].click + end it 'hides the diff content' do expect(small_diff).not_to have_selector('.code') @@ -232,7 +260,12 @@ feature 'Expand and collapse diffs', js: true, feature: true do end context 're-expanding the same diff' do - before { click_link('small_diff.md') } + before do + # Wait for diffs + find('.file-title', match: :first) + # Click `small_diff.md` title + all('.file-title')[3].click + end it 'shows the diff content' do expect(small_diff).to have_selector('.code') diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index a89ac09f236..84d73d693bc 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -23,25 +23,25 @@ describe "GitLab Flavored Markdown", feature: true do end describe "for commits" do - it "should render title in commits#index" do + it "renders title in commits#index" do visit namespace_project_commits_path(project.namespace, project, 'master', limit: 1) expect(page).to have_link(issue.to_reference) end - it "should render title in commits#show" do + it "renders title in commits#show" do visit namespace_project_commit_path(project.namespace, project, commit) expect(page).to have_link(issue.to_reference) end - it "should render description in commits#show" do + it "renders description in commits#show" do visit namespace_project_commit_path(project.namespace, project, commit) expect(page).to have_link(fred.to_reference) end - it "should render title in repositories#branches" do + it "renders title in repositories#branches" do visit namespace_project_branches_path(project.namespace, project) expect(page).to have_link(issue.to_reference) @@ -62,19 +62,19 @@ describe "GitLab Flavored Markdown", feature: true do description: "ask #{fred.to_reference} for details") end - it "should render subject in issues#index" do + it "renders subject in issues#index" do visit namespace_project_issues_path(project.namespace, project) expect(page).to have_link(@other_issue.to_reference) end - it "should render subject in issues#show" do + it "renders subject in issues#show" do visit namespace_project_issue_path(project.namespace, project, @issue) expect(page).to have_link(@other_issue.to_reference) end - it "should render details in issues#show" do + it "renders details in issues#show" do visit namespace_project_issue_path(project.namespace, project, @issue) expect(page).to have_link(fred.to_reference) @@ -86,13 +86,13 @@ describe "GitLab Flavored Markdown", feature: true do @merge_request = create(:merge_request, source_project: project, target_project: project, title: "fix #{issue.to_reference}") end - it "should render title in merge_requests#index" do + it "renders title in merge_requests#index" do visit namespace_project_merge_requests_path(project.namespace, project) expect(page).to have_link(issue.to_reference) end - it "should render title in merge_requests#show" do + it "renders title in merge_requests#show" do visit namespace_project_merge_request_path(project.namespace, project, @merge_request) expect(page).to have_link(issue.to_reference) @@ -107,19 +107,19 @@ describe "GitLab Flavored Markdown", feature: true do description: "ask #{fred.to_reference} for details") end - it "should render title in milestones#index" do + it "renders title in milestones#index" do visit namespace_project_milestones_path(project.namespace, project) expect(page).to have_link(issue.to_reference) end - it "should render title in milestones#show" do + it "renders title in milestones#show" do visit namespace_project_milestone_path(project.namespace, project, @milestone) expect(page).to have_link(issue.to_reference) end - it "should render description in milestones#show" do + it "renders description in milestones#show" do visit namespace_project_milestone_path(project.namespace, project, @milestone) expect(page).to have_link(fred.to_reference) diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 2d8b59472e8..c54ec2563ad 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -5,6 +5,12 @@ feature 'Group', feature: true do login_as(:admin) end + matcher :have_namespace_error_message do + match do |page| + page.has_content?("Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-' or end in '.', '.git' or '.atom'.") + end + end + describe 'creating a group with space in group path' do it 'renders new group form with validation errors' do visit new_group_path @@ -13,7 +19,31 @@ feature 'Group', feature: true do click_button 'Create group' expect(current_path).to eq(groups_path) - expect(page).to have_content("Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-' or end in '.'.") + expect(page).to have_namespace_error_message + end + end + + describe 'creating a group with .atom at end of group path' do + it 'renders new group form with validation errors' do + visit new_group_path + fill_in 'Group path', with: 'atom_group.atom' + + click_button 'Create group' + + expect(current_path).to eq(groups_path) + expect(page).to have_namespace_error_message + end + end + + describe 'creating a group with .git at end of group path' do + it 'renders new group form with validation errors' do + visit new_group_path + fill_in 'Group path', with: 'git_group.git' + + click_button 'Create group' + + expect(current_path).to eq(groups_path) + expect(page).to have_namespace_error_message end end diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb index 1e2306d7f59..e2101b333e2 100644 --- a/spec/features/help_pages_spec.rb +++ b/spec/features/help_pages_spec.rb @@ -5,7 +5,7 @@ describe 'Help Pages', feature: true do before do login_as :user end - it 'replace the variable $your_email with the email of the user' do + it 'replaces the variable $your_email with the email of the user' do visit help_page_path('ssh/README') expect(page).to have_content("ssh-keygen -t rsa -C \"#{@user.email}\"") end diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb index 0d495cd04aa..9a2b879e789 100644 --- a/spec/features/issuables/default_sort_order_spec.rb +++ b/spec/features/issuables/default_sort_order_spec.rb @@ -55,7 +55,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last updated"' do visit_merge_requests_with_state(project, 'merged') - expect(selected_sort_order).to eq('last updated') + expect(find('.issues-other-filters')).to have_content('Last updated') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -67,7 +67,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last updated"' do visit_merge_requests_with_state(project, 'closed') - expect(selected_sort_order).to eq('last updated') + expect(find('.issues-other-filters')).to have_content('Last updated') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -79,7 +79,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_merge_requests_with_state(project, 'all') - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_merge_request).to include(last_created_issuable.title) expect(last_merge_request).to include(first_created_issuable.title) end @@ -108,7 +108,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_issues project - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -120,7 +120,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_issues_with_state(project, 'open') - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -132,7 +132,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last updated"' do visit_issues_with_state(project, 'closed') - expect(selected_sort_order).to eq('last updated') + expect(find('.issues-other-filters')).to have_content('Last updated') expect(first_issue).to include(last_updated_issuable.title) expect(last_issue).to include(first_updated_issuable.title) end @@ -144,11 +144,35 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_issues_with_state(project, 'all') - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'when the sort in the URL is id_desc' do + let(:issuable_type) { :issue } + + before { visit_issues(project, sort: 'id_desc') } + + it 'shows the sort order as last created' do + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end end + + context 'when the sort in the URL is id_asc' do + let(:issuable_type) { :issue } + + before { visit_issues(project, sort: 'id_asc') } + + it 'shows the sort order as oldest created' do + expect(find('.issues-other-filters')).to have_content('Oldest created') + expect(first_issue).to include(first_created_issuable.title) + expect(last_issue).to include(last_created_issuable.title) + end + end end def selected_sort_order diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 07a854ea014..79cc50bc18e 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -12,7 +12,6 @@ describe 'Awards Emoji', feature: true do describe 'Click award emoji from issue#show' do let!(:issue) do create(:issue, - author: @user, assignee: @user, project: project) end @@ -21,32 +20,32 @@ describe 'Awards Emoji', feature: true do visit namespace_project_issue_path(project.namespace, project, issue) end - it 'should increment the thumbsdown emoji', js: true do + it 'increments the thumbsdown emoji', js: true do find('[data-emoji="thumbsdown"]').click sleep 2 expect(thumbsdown_emoji).to have_text("1") end context 'click the thumbsup emoji' do - it 'should increment the thumbsup emoji', js: true do + it 'increments the thumbsup emoji', js: true do find('[data-emoji="thumbsup"]').click sleep 2 expect(thumbsup_emoji).to have_text("1") end - it 'should decrement the thumbsdown emoji', js: true do + it 'decrements the thumbsdown emoji', js: true do expect(thumbsdown_emoji).to have_text("0") end end context 'click the thumbsdown emoji' do - it 'should increment the thumbsdown emoji', js: true do + it 'increments the thumbsdown emoji', js: true do find('[data-emoji="thumbsdown"]').click sleep 2 expect(thumbsdown_emoji).to have_text("1") end - it 'should decrement the thumbsup emoji', js: true do + it 'decrements the thumbsup emoji', js: true do expect(thumbsup_emoji).to have_text("0") end end diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb index 63efecf8780..401e1ea2b89 100644 --- a/spec/features/issues/award_spec.rb +++ b/spec/features/issues/award_spec.rb @@ -11,7 +11,7 @@ feature 'Issue awards', js: true, feature: true do visit namespace_project_issue_path(project.namespace, project, issue) end - it 'should add award to issue' do + it 'adds award to issue' do first('.js-emoji-btn').click expect(page).to have_selector('.js-emoji-btn.active') expect(first('.js-emoji-btn')).to have_content '1' @@ -20,7 +20,7 @@ feature 'Issue awards', js: true, feature: true do expect(first('.js-emoji-btn')).to have_content '1' end - it 'should remove award from issue' do + it 'removes award from issue' do first('.js-emoji-btn').click find('.js-emoji-btn.active').click expect(first('.js-emoji-btn')).to have_content '0' @@ -29,7 +29,7 @@ feature 'Issue awards', js: true, feature: true do expect(first('.js-emoji-btn')).to have_content '0' end - it 'should only have one menu on the page' do + it 'only has one menu on the page' do first('.js-add-award').click expect(page).to have_selector('.emoji-menu') @@ -42,7 +42,7 @@ feature 'Issue awards', js: true, feature: true do visit namespace_project_issue_path(project.namespace, project, issue) end - it 'should not see award menu button' do + it 'does not see award menu button' do expect(page).not_to have_selector('.js-award-holder') end end diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb index afc093cc1f5..bc2c087c9b9 100644 --- a/spec/features/issues/bulk_assignment_labels_spec.rb +++ b/spec/features/issues/bulk_assignment_labels_spec.rb @@ -175,7 +175,7 @@ feature 'Issues > Labels bulk assignment', feature: true do visit namespace_project_issues_path(project.namespace, project) end - it 'labels are kept' do + it 'keeps labels' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue2.id}")).to have_content 'feature' @@ -197,7 +197,7 @@ feature 'Issues > Labels bulk assignment', feature: true do visit namespace_project_issues_path(project.namespace, project) end - it 'existing label is kept and new label is present' do + it 'keeps existing label and new label is present' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' check 'check_all_issues' @@ -222,7 +222,7 @@ feature 'Issues > Labels bulk assignment', feature: true do visit namespace_project_issues_path(project.namespace, project) end - it 'existing label is kept and new label is present' do + it 'keeps existing label and new label is present' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue2.id}")).to have_content 'feature' @@ -252,7 +252,7 @@ feature 'Issues > Labels bulk assignment', feature: true do visit namespace_project_issues_path(project.namespace, project) end - it 'labels are kept' do + it 'keeps labels' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue1.id}")).to have_content 'First Release' expect(find("#issue_#{issue2.id}")).to have_content 'feature' diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/issues/filter_by_labels_spec.rb index cb117d2476f..0253629f753 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/issues/filter_by_labels_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' -feature 'Issue filtering by Labels', feature: true do +feature 'Issue filtering by Labels', feature: true, js: true do include WaitForAjax let(:project) { create(:project, :public) } - let!(:user) { create(:user)} + let!(:user) { create(:user) } let!(:label) { create(:label, project: project) } before do @@ -28,156 +28,81 @@ feature 'Issue filtering by Labels', feature: true do visit namespace_project_issues_path(project.namespace, project) end - context 'filter by label bug', js: true do + context 'filter by label bug' do before do - page.find('.js-label-select').click - wait_for_ajax - execute_script("$('.dropdown-menu-labels li:contains(\"bug\") a').click()") - page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - wait_for_ajax + select_labels('bug') end - it 'should show issue "Bugfix1" and "Bugfix2" in issues list' do + it 'apply the filter' do expect(page).to have_content "Bugfix1" expect(page).to have_content "Bugfix2" - end - - it 'should not show "Feature1" in issues list' do expect(page).not_to have_content "Feature1" - end - - it 'should show label "bug" in filtered-labels' do expect(find('.filtered-labels')).to have_content "bug" - end - - it 'should not show label "feature" and "enhancement" in filtered-labels' do expect(find('.filtered-labels')).not_to have_content "feature" expect(find('.filtered-labels')).not_to have_content "enhancement" - end - it 'should remove label "bug"' do find('.js-label-filter-remove').click wait_for_ajax expect(find('.filtered-labels', visible: false)).to have_no_content "bug" end end - context 'filter by label feature', js: true do + context 'filter by label feature' do before do - page.find('.js-label-select').click - wait_for_ajax - execute_script("$('.dropdown-menu-labels li:contains(\"feature\") a').click()") - page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - wait_for_ajax + select_labels('feature') end - it 'should show issue "Feature1" in issues list' do + it 'applies the filter' do expect(page).to have_content "Feature1" - end - - it 'should not show "Bugfix1" and "Bugfix2" in issues list' do expect(page).not_to have_content "Bugfix2" expect(page).not_to have_content "Bugfix1" - end - - it 'should show label "feature" in filtered-labels' do expect(find('.filtered-labels')).to have_content "feature" - end - - it 'should not show label "bug" and "enhancement" in filtered-labels' do expect(find('.filtered-labels')).not_to have_content "bug" expect(find('.filtered-labels')).not_to have_content "enhancement" end end - context 'filter by label enhancement', js: true do + context 'filter by label enhancement' do before do - page.find('.js-label-select').click - wait_for_ajax - execute_script("$('.dropdown-menu-labels li:contains(\"enhancement\") a').click()") - page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - wait_for_ajax + select_labels('enhancement') end - it 'should show issue "Bugfix2" in issues list' do + it 'applies the filter' do expect(page).to have_content "Bugfix2" - end - - it 'should not show "Feature1" and "Bugfix1" in issues list' do expect(page).not_to have_content "Feature1" expect(page).not_to have_content "Bugfix1" - end - - it 'should show label "enhancement" in filtered-labels' do expect(find('.filtered-labels')).to have_content "enhancement" - end - - it 'should not show label "feature" and "bug" in filtered-labels' do expect(find('.filtered-labels')).not_to have_content "bug" expect(find('.filtered-labels')).not_to have_content "feature" end end - context 'filter by label enhancement or feature', js: true do + context 'filter by label enhancement and bug in issues list' do before do - page.find('.js-label-select').click - wait_for_ajax - execute_script("$('.dropdown-menu-labels li:contains(\"enhancement\") a').click()") - execute_script("$('.dropdown-menu-labels li:contains(\"feature\") a').click()") - page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - wait_for_ajax + select_labels('bug', 'enhancement') end - it 'should not show "Bugfix1" or "Feature1" in issues list' do - expect(page).not_to have_content "Bugfix1" + it 'applies the filters' do + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) + expect(page).to have_content "Bugfix2" expect(page).not_to have_content "Feature1" - end - - it 'should show label "enhancement" and "feature" in filtered-labels' do + expect(find('.filtered-labels')).to have_content "bug" expect(find('.filtered-labels')).to have_content "enhancement" - expect(find('.filtered-labels')).to have_content "feature" - end - - it 'should not show label "bug" in filtered-labels' do - expect(find('.filtered-labels')).not_to have_content "bug" - end + expect(find('.filtered-labels')).not_to have_content "feature" - it 'should remove label "enhancement"' do find('.js-label-filter-remove', match: :first).click wait_for_ajax - expect(find('.filtered-labels')).to have_no_content "enhancement" - end - end - - context 'filter by label enhancement and bug in issues list', js: true do - before do - page.find('.js-label-select').click - wait_for_ajax - execute_script("$('.dropdown-menu-labels li:contains(\"enhancement\") a').click()") - execute_script("$('.dropdown-menu-labels li:contains(\"bug\") a').click()") - page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - wait_for_ajax - end - it 'should show issue "Bugfix2" in issues list' do expect(page).to have_content "Bugfix2" - end - - it 'should not show "Feature1"' do expect(page).not_to have_content "Feature1" - end - - it 'should show label "bug" and "enhancement" in filtered-labels' do - expect(find('.filtered-labels')).to have_content "bug" + expect(page).not_to have_content "Bugfix1" + expect(find('.filtered-labels')).not_to have_content "bug" expect(find('.filtered-labels')).to have_content "enhancement" - end - - it 'should not show label "feature" in filtered-labels' do expect(find('.filtered-labels')).not_to have_content "feature" end end - context 'remove filtered labels', js: true do + context 'remove filtered labels' do before do page.within '.labels-filter' do click_button 'Label' @@ -191,7 +116,7 @@ feature 'Issue filtering by Labels', feature: true do end end - it 'should allow user to remove filtered labels' do + it 'allows user to remove filtered labels' do first('.js-label-filter-remove').click wait_for_ajax @@ -200,8 +125,8 @@ feature 'Issue filtering by Labels', feature: true do end end - context 'dropdown filtering', js: true do - it 'should filter by label name' do + context 'dropdown filtering' do + it 'filters by label name' do page.within '.labels-filter' do click_button 'Label' wait_for_ajax @@ -214,4 +139,14 @@ feature 'Issue filtering by Labels', feature: true do end end end + + def select_labels(*labels) + page.find('.js-label-select').click + wait_for_ajax + labels.each do |label| + execute_script("$('.dropdown-menu-labels li:contains(\"#{label}\") a').click()") + end + page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click + wait_for_ajax + end end diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb index 99445185893..485dc560061 100644 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ b/spec/features/issues/filter_by_milestone_spec.rb @@ -15,7 +15,7 @@ feature 'Issue filtering by Milestone', feature: true do end context 'filters by upcoming milestone', js: true do - it 'should not show issues with no expiry' do + it 'does not show issues with no expiry' do create(:issue, project: project) create(:issue, project: project, milestone: milestone) @@ -25,7 +25,7 @@ feature 'Issue filtering by Milestone', feature: true do expect(page).to have_css('.issue', count: 0) end - it 'should show issues in future' do + 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) @@ -36,7 +36,7 @@ feature 'Issue filtering by Milestone', feature: true do expect(page).to have_css('.issue', count: 1) end - it 'should not show issues in past' do + 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) diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb index 4b9b5394b61..78208aed46d 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/issues/filter_issues_spec.rb @@ -7,14 +7,15 @@ describe 'Filter issues', feature: true do let!(:user) { create(:user)} let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } - let!(:issue1) { create(:issue, project: project) } + let!(:wontfix) { create(:label, project: project, title: "Won't fix") } before do project.team << [user, :master] login_as(user) + create(:issue, project: project) end - describe 'Filter issues for assignee from issues#index' do + describe 'for assignee from issues#index' do before do visit namespace_project_issues_path(project.namespace, project) @@ -26,17 +27,17 @@ describe 'Filter issues', feature: true do end context 'assignee', js: true do - it 'should update to current user' do + it 'updates to current user' do expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) end - it 'should not change when closed link is clicked' do + it 'does not change when closed link is clicked' do find('.issues-state-filters a', text: "Closed").click expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) end - it 'should not change when all link is clicked' do + it 'does not change when all link is clicked' do find('.issues-state-filters a', text: "All").click expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) @@ -44,7 +45,7 @@ describe 'Filter issues', feature: true do end end - describe 'Filter issues for milestone from issues#index' do + describe 'for milestone from issues#index' do before do visit namespace_project_issues_path(project.namespace, project) @@ -56,17 +57,17 @@ describe 'Filter issues', feature: true do end context 'milestone', js: true do - it 'should update to current milestone' do + it 'updates to current milestone' do expect(find('.js-milestone-select .dropdown-toggle-text')).to have_content(milestone.title) end - it 'should not change when closed link is clicked' do + it 'does not change when closed link is clicked' do find('.issues-state-filters a', text: "Closed").click expect(find('.js-milestone-select .dropdown-toggle-text')).to have_content(milestone.title) end - it 'should not change when all link is clicked' do + it 'does not change when all link is clicked' do find('.issues-state-filters a', text: "All").click expect(find('.js-milestone-select .dropdown-toggle-text')).to have_content(milestone.title) @@ -74,14 +75,14 @@ describe 'Filter issues', feature: true do end end - describe 'Filter issues for label from issues#index', js: true do + describe 'for label from issues#index', js: true do before do visit namespace_project_issues_path(project.namespace, project) find('.js-label-select').click wait_for_ajax end - it 'should filter by any label' do + it 'filters by any label' do find('.dropdown-menu-labels a', text: 'Any Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click wait_for_ajax @@ -89,27 +90,71 @@ describe 'Filter issues', feature: true do expect(find('.labels-filter')).to have_content 'Label' end - it 'should filter by no label' do + it 'filters by no label' do find('.dropdown-menu-labels a', text: 'No Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click wait_for_ajax page.within '.labels-filter' do - expect(page).to have_content 'No Label' + expect(page).to have_content 'Labels' end - expect(find('.js-label-select .dropdown-toggle-text')).to have_content('No Label') + expect(find('.js-label-select .dropdown-toggle-text')).to have_content('Labels') end - it 'should filter by no label' do + it 'filters by a label' do find('.dropdown-menu-labels a', text: label.title).click page.within '.labels-filter' do expect(page).to have_content label.title end expect(find('.js-label-select .dropdown-toggle-text')).to have_content(label.title) end + + it "filters by `won't fix` and another label" do + page.within '.labels-filter' do + click_link wontfix.title + expect(page).to have_content wontfix.title + click_link label.title + end + + expect(find('.js-label-select .dropdown-toggle-text')).to have_content("#{wontfix.title} +1 more") + end + + it "filters by `won't fix` label followed by another label after page load" do + page.within '.labels-filter' do + click_link wontfix.title + expect(page).to have_content wontfix.title + end + + find('body').click + + expect(find('.filtered-labels')).to have_content(wontfix.title) + + find('.js-label-select').click + wait_for_ajax + find('.dropdown-menu-labels a', text: label.title).click + + find('body').click + + expect(find('.filtered-labels')).to have_content(wontfix.title) + expect(find('.filtered-labels')).to have_content(label.title) + + find('.js-label-select').click + wait_for_ajax + + expect(find('.dropdown-menu-labels li', text: wontfix.title)).to have_css('.is-active') + expect(find('.dropdown-menu-labels li', text: label.title)).to have_css('.is-active') + end + + 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 + # Close label dropdown to load + find('body').click + expect(page).not_to have_css('.filtered-labels') + end end - describe 'Filter issues for assignee and label from issues#index' do + describe 'for assignee and label from issues#index' do before do visit namespace_project_issues_path(project.namespace, project) @@ -117,7 +162,7 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - wait_for_ajax + expect(page).not_to have_selector('.issues-list .issue') find('.js-label-select').click @@ -128,19 +173,19 @@ describe 'Filter issues', feature: true do end context 'assignee and label', js: true do - it 'should update to current assignee and label' do + it 'updates to current assignee and label' do expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) expect(find('.js-label-select .dropdown-toggle-text')).to have_content(label.title) end - it 'should not change when closed link is clicked' do + it 'does not change when closed link is clicked' do find('.issues-state-filters a', text: "Closed").click expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) expect(find('.js-label-select .dropdown-toggle-text')).to have_content(label.title) end - it 'should not change when all link is clicked' do + it 'does not change when all link is clicked' do find('.issues-state-filters a', text: "All").click expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) @@ -168,16 +213,16 @@ describe 'Filter issues', feature: true do end context 'only text', js: true do - it 'should filter issues by searched text' do - fill_in 'issue_search', with: 'Bug' + it 'filters issues by searched text' do + fill_in 'issuable_search', with: 'Bug' page.within '.issues-list' do expect(page).to have_selector('.issue', count: 2) end end - it 'should not show any issues' do - fill_in 'issue_search', with: 'testing' + it 'does not show any issues' do + fill_in 'issuable_search', with: 'testing' page.within '.issues-list' do expect(page).not_to have_selector('.issue') @@ -186,9 +231,10 @@ describe 'Filter issues', feature: true do end context 'text and dropdown options', js: true do - it 'should filter by text and label' do - fill_in 'issue_search', with: 'Bug' + it 'filters by text and label' 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) end @@ -199,14 +245,16 @@ describe 'Filter issues', feature: true do end 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) end end - it 'should filter by text and milestone' do - fill_in 'issue_search', with: 'Bug' + it 'filters by text and milestone' 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) end @@ -216,14 +264,16 @@ describe 'Filter issues', feature: true do click_link '8' 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) end end - it 'should filter by text and assignee' do - fill_in 'issue_search', with: 'Bug' + it 'filters by text and assignee' 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) end @@ -233,14 +283,16 @@ describe 'Filter issues', feature: true do click_link user.name 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) end end - it 'should filter by text and author' do - fill_in 'issue_search', with: 'Bug' + it 'filters by text and author' 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) end @@ -250,6 +302,7 @@ describe 'Filter issues', feature: true do click_link user.name 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) end @@ -269,7 +322,7 @@ describe 'Filter issues', feature: true do visit namespace_project_issues_path(project.namespace, project) end - it 'should be able to filter and sort issues' do + it 'is able to filter and sort issues' do click_button 'Label' wait_for_ajax page.within '.labels-filter' do @@ -278,6 +331,7 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-close-icon').click 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) end diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb new file mode 100644 index 00000000000..8771cc8e157 --- /dev/null +++ b/spec/features/issues/form_spec.rb @@ -0,0 +1,119 @@ +require 'rails_helper' + +describe 'New/edit issue', feature: true, js: true do + let!(:project) { create(:project) } + let!(:user) { create(:user)} + let!(:milestone) { create(:milestone, project: project) } + let!(:label) { create(:label, project: project) } + let!(:label2) { create(:label, project: project) } + let!(:issue) { create(:issue, project: project, assignee: user, milestone: milestone) } + + before do + project.team << [user, :master] + login_as(user) + end + + context 'new issue' do + before do + visit new_namespace_project_issue_path(project.namespace, project) + end + + it 'allows user to create new issue' do + fill_in 'issue_title', with: 'title' + fill_in 'issue_description', with: 'title' + + click_button 'Assignee' + page.within '.dropdown-menu-user' do + click_link user.name + end + expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user.name + end + + click_button 'Milestone' + page.within '.issue-milestone' do + click_link milestone.title + end + expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + page.within '.js-label-select' do + expect(page).to have_content label.title + end + expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + + click_button 'Submit issue' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end + end + end + end + + context 'edit issue' do + before do + visit edit_namespace_project_issue_path(project.namespace, project, issue) + end + + it 'allows user to update issue' do + expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + + page.within '.js-user-search' do + expect(page).to have_content user.name + end + + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + page.within '.js-label-select' do + expect(page).to have_content label.title + end + expect(page.all('input[name="issue[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="issue[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + + click_button 'Save changes' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end + end + end + end +end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 5739bc64dfb..4b1aec8bf71 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -17,7 +17,7 @@ feature 'Issue Sidebar', feature: true do end describe 'when clicking on edit labels', js: true do - it 'dropdown has an option to create a new label' do + it 'shows dropdown option to create a new label' do find('.block.labels .edit-link').click page.within('.block.labels') do @@ -27,7 +27,7 @@ feature 'Issue Sidebar', feature: true do end context 'creating a new label', js: true do - it 'option to crate a new label is present' do + it 'shows option to crate a new label is present' do page.within('.block.labels') do find('.edit-link').click @@ -35,7 +35,7 @@ feature 'Issue Sidebar', feature: true do end end - it 'dropdown switches to "create label" section' do + it 'shows dropdown switches to "create label" section' do page.within('.block.labels') do find('.edit-link').click click_link 'Create new' @@ -44,7 +44,7 @@ feature 'Issue Sidebar', feature: true do end end - it 'new label is added' do + it 'adds new label' do page.within('.block.labels') do find('.edit-link').click sleep 1 diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 7773c486b4e..055210399a7 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -55,7 +55,7 @@ feature 'issue move to another project' do first('.select2-choice').click end - fill_in('s2id_autogen2_search', with: new_project_search.name) + fill_in('s2id_autogen1_search', with: new_project_search.name) page.within '.select2-drop' do expect(page).to have_content(new_project_search.name) diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb index 16e188d2a8a..fb0c4704285 100644 --- a/spec/features/issues/new_branch_button_spec.rb +++ b/spec/features/issues/new_branch_button_spec.rb @@ -20,7 +20,7 @@ feature 'Start new branch from an issue', feature: true do context "when there is a referenced merge request" do let(:note) do create(:note, :on_issue, :system, project: project, - note: "mentioned in !#{referenced_mr.iid}") + note: "Mentioned in !#{referenced_mr.iid}") end let(:referenced_mr) do create(:merge_request, :simple, source_project: project, target_project: project, @@ -41,7 +41,7 @@ feature 'Start new branch from an issue', feature: true do end context "for visiters" do - it 'no button is shown', js: true do + it 'shows no buttons', js: true do visit namespace_project_issue_path(project.namespace, project, issue) expect(page).not_to have_css('#new-branch') diff --git a/spec/features/issues/reset_filters_spec.rb b/spec/features/issues/reset_filters_spec.rb new file mode 100644 index 00000000000..f4d0f13c3d5 --- /dev/null +++ b/spec/features/issues/reset_filters_spec.rb @@ -0,0 +1,81 @@ +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 + + def reset_filters + find('.reset-filters').click + end +end diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb index bc0f437a8ce..de8fdda388d 100644 --- a/spec/features/issues/todo_spec.rb +++ b/spec/features/issues/todo_spec.rb @@ -11,7 +11,7 @@ feature 'Manually create a todo item from issue', feature: true, js: true do visit namespace_project_issue_path(project.namespace, project, issue) end - it 'should create todo when clicking button' do + it 'creates todo when clicking button' do page.within '.issuable-sidebar' do click_button 'Add Todo' expect(page).to have_content 'Mark Done' @@ -28,7 +28,7 @@ feature 'Manually create a todo item from issue', feature: true, js: true do end end - it 'should mark a todo as done' do + it 'marks a todo as done' do page.within '.issuable-sidebar' do click_button 'Add Todo' click_button 'Mark Done' diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index ddbd69b2891..ae5da3877a8 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -13,7 +13,7 @@ feature 'Multiple issue updating from issues#index', feature: true do end context 'status', js: true do - it 'should be set to closed' do + it 'sets to closed' do visit namespace_project_issues_path(project.namespace, project) find('#check_all_issues').click @@ -24,7 +24,7 @@ feature 'Multiple issue updating from issues#index', feature: true do expect(page).to have_selector('.issue', count: 0) end - it 'should be set to open' do + it 'sets to open' do create_closed visit namespace_project_issues_path(project.namespace, project, state: 'closed') @@ -38,7 +38,7 @@ feature 'Multiple issue updating from issues#index', feature: true do end context 'assignee', js: true do - it 'should update to current user' do + it 'updates to current user' do visit namespace_project_issues_path(project.namespace, project) find('#check_all_issues').click @@ -52,7 +52,7 @@ feature 'Multiple issue updating from issues#index', feature: true do end end - it 'should update to unassigned' do + it 'updates to unassigned' do create_assigned visit namespace_project_issues_path(project.namespace, project) @@ -68,7 +68,7 @@ feature 'Multiple issue updating from issues#index', feature: true do context 'milestone', js: true do let(:milestone) { create(:milestone, project: project) } - it 'should update milestone' do + it 'updates milestone' do visit namespace_project_issues_path(project.namespace, project) find('#check_all_issues').click @@ -80,7 +80,7 @@ feature 'Multiple issue updating from issues#index', feature: true do expect(find('.issue')).to have_content milestone.title end - it 'should set to no milestone' do + it 'sets to no milestone' do create_with_milestone visit namespace_project_issues_path(project.namespace, project) diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb new file mode 100644 index 00000000000..3f2da1c380c --- /dev/null +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +feature 'Issues > User uses slash commands', feature: true, js: true do + include SlashCommandsHelpers + include WaitForAjax + + it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do + let(:issuable) { create(:issue, project: project) } + end + + describe 'issue-only commands' do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_with(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + after do + wait_for_ajax + end + + describe 'adding a due date from note' do + let(:issue) { create(:issue, project: project) } + + context 'when the current user can update the due date' do + it 'does not create a note, and sets the due date accordingly' do + write_note("/due 2016-08-28") + + expect(page).not_to have_content '/due 2016-08-28' + expect(page).to have_content 'Your commands have been executed!' + + issue.reload + + expect(issue.due_date).to eq Date.new(2016, 8, 28) + end + end + + context 'when the current user cannot update the due date' do + let(:guest) { create(:user) } + before do + project.team << [guest, :guest] + logout + login_with(guest) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'does not create a note, and sets the due date accordingly' do + write_note("/due 2016-08-28") + + expect(page).to have_content '/due 2016-08-28' + expect(page).not_to have_content 'Your commands have been executed!' + + issue.reload + + expect(issue.due_date).to be_nil + end + end + end + + describe 'removing a due date from note' do + let(:issue) { create(:issue, project: project, due_date: Date.new(2016, 8, 28)) } + + context 'when the current user can update the due date' do + it 'does not create a note, and removes the due date accordingly' do + expect(issue.due_date).to eq Date.new(2016, 8, 28) + + write_note("/remove_due_date") + + expect(page).not_to have_content '/remove_due_date' + expect(page).to have_content 'Your commands have been executed!' + + issue.reload + + expect(issue.due_date).to be_nil + end + end + + context 'when the current user cannot update the due date' do + let(:guest) { create(:user) } + before do + project.team << [guest, :guest] + logout + login_with(guest) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'does not create a note, and sets the due date accordingly' do + write_note("/remove_due_date") + + expect(page).to have_content '/remove_due_date' + expect(page).not_to have_content 'Your commands have been executed!' + + issue.reload + + expect(issue.due_date).to eq Date.new(2016, 8, 28) + end + end + end + + describe 'toggling the WIP prefix from the title from note' do + let(:issue) { create(:issue, project: project) } + + it 'does not recognize the command nor create a note' do + write_note("/wip") + + expect(page).not_to have_content '/wip' + end + end + end +end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 9c92b52898c..b504329656f 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -26,7 +26,7 @@ describe 'Issues', feature: true do find('.js-zen-enter').click end - it 'should open new issue popup' do + it 'opens new issue popup' do expect(page).to have_content("Issue ##{issue.iid}") end @@ -51,9 +51,8 @@ describe 'Issues', feature: true do expect(page).to have_content "Assignee #{@user.name}" - first('#s2id_issue_assignee_id').click - sleep 2 # wait for ajax stuff to complete - first('.user-result').click + first('.js-user-search').click + click_link 'Unassigned' click_button 'Save changes' @@ -71,7 +70,7 @@ describe 'Issues', feature: true do visit new_namespace_project_issue_path(project.namespace, project) end - it 'should save with due date' do + it 'saves with due date' do date = Date.today.at_beginning_of_month fill_in 'issue_title', with: 'bug 345' @@ -99,7 +98,7 @@ describe 'Issues', feature: true do visit edit_namespace_project_issue_path(project.namespace, project, issue) end - it 'should save with due date' do + it 'saves with due date' do date = Date.today.at_beginning_of_month expect(find('#issuable-due-date').value).to eq date.to_s @@ -122,6 +121,17 @@ describe 'Issues', feature: true do expect(page).to have_content date.to_s(:medium) end end + + it 'warns about version conflict' do + issue.update(title: "New title") + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + + click_button 'Save changes' + + expect(page).to have_content 'Someone edited the issue the same time you did' + end end end @@ -133,7 +143,7 @@ describe 'Issues', feature: true do visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) expect(page).to have_content 'foobar' - expect(page.all('.issue-no-comments').first.text).to eq "0" + expect(page.all('.no-comments').first.text).to eq "0" end end @@ -155,7 +165,7 @@ describe 'Issues', feature: true do let(:issue) { @issue } - it 'should allow filtering by issues with no specified assignee' do + it 'allows filtering by issues with no specified assignee' do visit namespace_project_issues_path(project.namespace, project, assignee_id: IssuableFinder::NONE) expect(page).to have_content 'foobar' @@ -163,7 +173,7 @@ describe 'Issues', feature: true do expect(page).not_to have_content 'gitlab' end - it 'should allow filtering by a specified assignee' do + it 'allows filtering by a specified assignee' do visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) expect(page).not_to have_content 'foobar' @@ -358,6 +368,24 @@ describe 'Issues', feature: true do end end + describe 'update labels from issue#show', js: true do + let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let!(:label) { create(:label, project: project) } + + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'will not send ajax request when no data is changed' do + page.within '.labels' do + click_link 'Edit' + first('.dropdown-menu-close').click + + expect(page).not_to have_selector('.block-loading') + end + end + end + describe 'update assignee from issue#show' do let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } @@ -514,7 +542,7 @@ describe 'Issues', feature: true do visit new_namespace_project_issue_path(project.namespace, project) end - it 'should upload file when dragging into textarea' do + it 'uploads file when dragging into textarea' do drop_in_dropzone test_image_file # Wait for the file to upload @@ -525,7 +553,7 @@ describe 'Issues', feature: true do end end - describe 'new issue by email' do + xdescribe 'new issue by email' do shared_examples 'show the email in the modal' do before do stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") @@ -562,7 +590,7 @@ describe 'Issues', feature: true do visit namespace_project_issue_path(project.namespace, project, issue) end - it 'should add due date to issue' do + it 'adds due date to issue' do page.within '.due_date' do click_link 'Edit' @@ -574,7 +602,7 @@ describe 'Issues', feature: true do end end - it 'should remove due date from issue' do + it 'removes due date from issue' do page.within '.due_date' do click_link 'Edit' diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index 58753ff21f6..2523b4b7898 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -128,10 +128,10 @@ feature 'Login', feature: true do end allow(Gitlab::OAuth::Provider).to receive_messages(providers: [:saml], config_for: saml_config) allow(Gitlab.config.omniauth).to receive_messages(messages) - allow_any_instance_of(Object).to receive(:user_omniauth_authorize_path).with('saml').and_return('/users/auth/saml') + expect_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml') end - it 'should show 2FA prompt after OAuth login' do + it 'shows 2FA prompt after OAuth login' do stub_omniauth_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], providers: [saml_config]) user = create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: 'saml') login_via('saml', user, 'my-uid') diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb new file mode 100644 index 00000000000..43cc6f2a2a7 --- /dev/null +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +feature 'Merge request issue assignment', js: true, feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:issue1) { create(:issue, project: project) } + let(:issue2) { create(:issue, project: project) } + let(:merge_request) { create(:merge_request, :simple, source_project: project, author: user, description: "fixes #{issue1.to_reference} and #{issue2.to_reference}") } + let(:service) { MergeRequests::AssignIssuesService.new(merge_request, user, user, project) } + + before do + project.team << [user, :developer] + end + + def visit_merge_request(current_user = nil) + login_as(current_user || user) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + context 'logged in as author' do + scenario 'updates related issues' do + visit_merge_request + click_link "Assign yourself to these issues" + + expect(page).to have_content "2 issues have been assigned to you" + end + + it 'returns user to the merge request' do + visit_merge_request + click_link "Assign yourself to these issues" + + expect(page).to have_content merge_request.description + end + + it "doesn't display if related issues are already assigned" do + [issue1, issue2].each { |issue| issue.update!(assignee: user) } + + visit_merge_request + + expect(page).not_to have_content "Assign yourself" + end + end + + context 'not MR author' do + it "doesn't not show assignment link" do + visit_merge_request(create(:user)) + + expect(page).not_to have_content "Assign yourself" + end + end +end diff --git a/spec/features/merge_requests/award_spec.rb b/spec/features/merge_requests/award_spec.rb index 007f67d6080..ac260e118d0 100644 --- a/spec/features/merge_requests/award_spec.rb +++ b/spec/features/merge_requests/award_spec.rb @@ -11,7 +11,7 @@ feature 'Merge request awards', js: true, feature: true do visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - it 'should add award to merge request' do + it 'adds award to merge request' do first('.js-emoji-btn').click expect(page).to have_selector('.js-emoji-btn.active') expect(first('.js-emoji-btn')).to have_content '1' @@ -20,7 +20,7 @@ feature 'Merge request awards', js: true, feature: true do expect(first('.js-emoji-btn')).to have_content '1' end - it 'should remove award from merge request' do + it 'removes award from merge request' do first('.js-emoji-btn').click find('.js-emoji-btn.active').click expect(first('.js-emoji-btn')).to have_content '0' @@ -29,7 +29,7 @@ feature 'Merge request awards', js: true, feature: true do expect(first('.js-emoji-btn')).to have_content '0' end - it 'should only have one menu on the page' do + it 'has only one menu on the page' do first('.js-add-award').click expect(page).to have_selector('.emoji-menu') @@ -42,7 +42,7 @@ feature 'Merge request awards', js: true, feature: true do visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - it 'should not see award menu button' do + it 'does not see award menu button' do expect(page).not_to have_selector('.js-award-holder') end end diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb new file mode 100644 index 00000000000..759edf8ec80 --- /dev/null +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +feature 'Merge request conflict resolution', js: true, feature: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project) } + + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end + + context 'when a merge request can be resolved in the UI' do + let(:merge_request) { create_merge_request('conflict-resolvable') } + + before do + project.team << [user, :developer] + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows a link to the conflict resolution page' do + expect(page).to have_link('conflicts', href: /\/conflicts\Z/) + end + + context 'visiting the conflicts resolution page' do + before { click_link('conflicts', href: /\/conflicts\Z/) } + + it 'shows the conflicts' do + begin + expect(find('#conflicts')).to have_content('popen.rb') + rescue Capybara::Poltergeist::JavascriptError + retry + end + end + end + end + + UNRESOLVABLE_CONFLICTS = { + 'conflict-too-large' => 'when the conflicts contain a large file', + 'conflict-binary-file' => 'when the conflicts contain a binary file', + 'conflict-contains-conflict-markers' => 'when the conflicts contain a file with ambiguous conflict markers', + 'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another', + 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file', + } + + UNRESOLVABLE_CONFLICTS.each do |source_branch, description| + context description do + let(:merge_request) { create_merge_request(source_branch) } + + before do + project.team << [user, :developer] + login_as(user) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to the conflict resolution page' do + expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/) + end + + it 'shows an error if the conflicts page is visited directly' do + visit current_url + '/conflicts' + wait_for_ajax + + expect(find('#conflicts')).to have_content('Please try to resolve them locally.') + end + end + end +end diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index e296078bad8..b963d1305b5 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -8,11 +8,14 @@ feature 'Create New Merge Request', feature: true, js: true do project.team << [user, :master] login_as user - visit namespace_project_merge_requests_path(project.namespace, project) end it 'generates a diff for an orphaned branch' do + visit namespace_project_merge_requests_path(project.namespace, project) + click_link 'New Merge Request' + expect(page).to have_content('Source branch') + expect(page).to have_content('Target branch') first('.js-source-branch').click first('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch').click @@ -40,4 +43,20 @@ feature 'Create New Merge Request', feature: true, js: true do expect(page).not_to have_content private_project.to_reference end end + + it 'allows to change the diff view' do + visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'fix' }) + + click_link 'Changes' + + expect(page).to have_css('a.btn.active', text: 'Inline') + expect(page).not_to have_css('a.btn.active', text: 'Side-by-side') + + click_link 'Side-by-side' + + within '.merge-request' do + expect(page).not_to have_css('a.btn.active', text: 'Inline') + expect(page).to have_css('a.btn.active', text: 'Side-by-side') + end + end end diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index f676200ecf3..4d5d4aa121a 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -29,12 +29,16 @@ feature 'Merge request created from fork' do include WaitForAjax given(:pipeline) do - create(:ci_pipeline_with_two_job, project: fork_project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch) + create(:ci_pipeline, + project: fork_project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch) end - background { pipeline.create_builds(user) } + background do + create(:ci_build, pipeline: pipeline, name: 'rspec') + create(:ci_build, pipeline: pipeline, name: 'spinach') + end scenario 'user visits a pipelines page', js: true do visit_merge_request(merge_request) diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb new file mode 100644 index 00000000000..c6adf7e4c56 --- /dev/null +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -0,0 +1,497 @@ +require 'spec_helper' + +feature 'Diff notes resolve', feature: true, js: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") } + let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 9, + diff_refs: merge_request.diff_refs + ) + end + + context 'no discussions' do + before do + project.team << [user, :master] + login_as user + note.destroy + visit_merge_request + end + + it 'displays no discussion resolved data' do + expect(page).not_to have_content('discussion resolved') + expect(page).not_to have_selector('.discussion-next-btn') + end + end + + context 'as authorized user' do + before do + project.team << [user, :master] + login_as user + visit_merge_request + end + + context 'single discussion' do + it 'shows text with how many discussions' do + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to mark a note as resolved' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + + expect(page).to have_selector('.line-resolve-btn.is-active') + expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + end + + page.within '.diff-content' do + expect(page).to have_selector('.btn', text: 'Unresolve discussion') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to mark discussion as resolved' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + page.within '.diff-content .note' do + expect(page).to have_selector('.line-resolve-btn.is-active') + + expect(find('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + click_button 'Unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'hides resolved discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + visit_merge_request + + expect(page).to have_selector('.discussion-body', visible: false) + end + + it 'allows user to resolve from reply form without a comment' do + page.within '.diff-content' do + click_button 'Reply...' + + click_button 'Resolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to unresolve from reply form without a comment' do + page.within '.diff-content' do + click_button 'Resolve discussion' + sleep 1 + + click_button 'Reply...' + + click_button 'Unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + expect(page).not_to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to comment & resolve discussion' do + page.within '.diff-content' do + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & resolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to comment & unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to quickly scroll to next unresolved discussion' do + page.within '.line-resolve-all-container' do + page.find('.discussion-next-btn').click + end + + expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 + end + + it 'hides jump to next button when all resolved' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + expect(page).to have_selector('.discussion-next-btn', visible: false) + end + + it 'updates updated text after resolving note' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + end + + expect(page).to have_content("Resolved by #{user.name}") + end + + it 'hides jump to next discussion button' do + page.within '.discussion-reply-holder' do + expect(page).not_to have_selector('.discussion-next-btn') + end + end + end + + context 'multiple notes' do + before do + create(:diff_note_on_merge_request, project: project, noteable: merge_request) + end + + it 'does not mark discussion as resolved when resolving single note' do + page.within '.diff-content .note' do + first('.line-resolve-btn').click + sleep 1 + expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") + end + + expect(page).to have_content('Last updated') + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'resolves discussion' do + page.all('.note').each do |note| + note.find('.line-resolve-btn').click + end + + expect(page).to have_content('Resolved by') + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + end + end + end + + context 'muliple discussions' do + before do + create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) + visit_merge_request + end + + it 'shows text with how many discussions' do + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/2 discussions resolved') + end + end + + it 'allows user to mark a single note as resolved' do + click_button('Resolve discussion', match: :first) + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/2 discussions resolved') + end + end + + it 'allows user to mark all notes as resolved' do + page.all('.line-resolve-btn').each do |btn| + btn.click + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('2/2 discussions resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user user to mark all discussions as resolved' do + page.all('.discussion-reply-holder').each do |reply_holder| + page.within reply_holder do + click_button 'Resolve discussion' + end + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('2/2 discussions resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to quickly scroll to next unresolved discussion' do + page.within first('.discussion-reply-holder') do + click_button 'Resolve discussion' + end + + page.within '.line-resolve-all-container' do + page.find('.discussion-next-btn').click + end + + expect(page.evaluate_script("$('body').scrollTop()")).to be > 0 + end + + it 'updates updated text after resolving note' do + page.within first('.diff-content .note') do + find('.line-resolve-btn').click + end + + expect(page).to have_content("Resolved by #{user.name}") + end + + it 'shows jump to next discussion button' do + page.all('.discussion-reply-holder').each do |holder| + expect(holder).to have_selector('.discussion-next-btn') + end + end + + it 'displays next discussion even if hidden' do + page.all('.note-discussion').each do |discussion| + page.within discussion do + click_link 'Toggle discussion' + end + end + + page.within('.issuable-discussion #notes') do + expect(page).not_to have_selector('.btn', text: 'Resolve discussion') + end + + page.within '.line-resolve-all-container' do + page.find('.discussion-next-btn').click + end + + expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion') + end + end + + context 'changes tab' do + it 'shows text with how many discussions' do + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to mark a note as resolved' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + page.within '.diff-content' do + expect(page).to have_selector('.btn', text: 'Unresolve discussion') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to mark discussion as resolved' do + page.within '.diff-content' do + click_button 'Resolve discussion' + end + + page.within '.diff-content .note' do + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + click_button 'Unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'allows user to comment & resolve discussion' do + page.within '.diff-content' do + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & resolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + + it 'allows user to comment & unresolve discussion' do + page.within '.diff-content' do + click_button 'Resolve discussion' + + click_button 'Reply...' + + find('.js-note-text').set 'testing' + + click_button 'Comment & unresolve discussion' + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + end + end + + context 'as a guest' do + let(:guest) { create(:user) } + + before do + project.team << [guest, :guest] + login_as guest + end + + context 'someone elses merge request' do + before do + visit_merge_request + end + + it 'does not allow user to mark note as resolved' do + page.within '.diff-content .note' do + expect(page).not_to have_selector('.line-resolve-btn') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + + it 'does not allow user to mark discussion as resolved' do + page.within '.diff-content .note' do + expect(page).not_to have_selector('.btn', text: 'Resolve discussion') + end + end + end + + context 'guest users merge request' do + before do + mr = create(:merge_request_with_diffs, source_project: project, source_branch: 'markdown', author: guest, title: "Bug") + create(:diff_note_on_merge_request, project: project, noteable: mr) + visit_merge_request(mr) + end + + it 'allows user to mark a note as resolved' do + page.within '.diff-content .note' do + find('.line-resolve-btn').click + + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + page.within '.diff-content' do + expect(page).to have_selector('.btn', text: 'Unresolve discussion') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('1/1 discussion resolved') + expect(page).to have_selector('.line-resolve-btn.is-active') + end + end + end + end + + context 'unauthorized user' do + context 'no resolved comments' do + before do + visit_merge_request + end + + it 'does not allow user to mark note as resolved' do + page.within '.diff-content .note' do + expect(page).not_to have_selector('.line-resolve-btn') + end + + page.within '.line-resolve-all-container' do + expect(page).to have_content('0/1 discussion resolved') + end + end + end + + context 'resolved comment' do + before do + note.resolve!(user) + visit_merge_request + end + + it 'shows resolved icon' do + expect(page).to have_content '1/1 discussion resolved' + + click_link 'Toggle discussion' + expect(page).to have_selector('.line-resolve-btn.is-active') + end + + it 'does not allow user to click resolve button' do + expect(page).to have_selector('.line-resolve-btn.is-disabled') + click_link 'Toggle discussion' + + expect(page).to have_selector('.line-resolve-btn.is-disabled') + end + end + end + + def visit_merge_request(mr = nil) + mr = mr || merge_request + visit namespace_project_merge_request_path(mr.project.namespace, mr.project, mr) + end +end diff --git a/spec/features/merge_requests/diff_notes_spec.rb b/spec/features/merge_requests/diff_notes_spec.rb new file mode 100644 index 00000000000..06fad1007e8 --- /dev/null +++ b/spec/features/merge_requests/diff_notes_spec.rb @@ -0,0 +1,238 @@ +require 'spec_helper' + +feature 'Diff notes', js: true, feature: true do + include WaitForAjax + + before do + login_as :admin + @merge_request = create(:merge_request) + @project = @merge_request.source_project + end + + context 'merge request diffs' do + let(:comment_button_class) { '.add-diff-note' } + let(:notes_holder_input_class) { 'js-temp-notes-holder' } + let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } + let(:test_note_comment) { 'this is a test note!' } + + context 'when hovering over a parallel view diff file' do + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, view: 'parallel') + end + + context 'with an old line on the left and no line on the right' do + it 'should allow commenting on the left side' do + should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'left') + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'right') + end + end + + context 'with no line on the left and a new line on the right' do + it 'should not allow commenting on the left side' do + should_not_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'left') + end + + it 'should allow commenting on the right side' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'right') + end + end + + context 'with an old line on the left and a new line on the right' do + it 'should allow commenting on the left side' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'left') + end + + it 'should allow commenting on the right side' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'right') + end + end + + context 'with an unchanged line on the left and an unchanged line on the right' do + it 'should allow commenting on the left side' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]', match: :first).find(:xpath, '..'), 'left') + end + + it 'should allow commenting on the right side' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]', match: :first).find(:xpath, '..'), 'right') + end + end + + context 'with a match line' do + it 'should not allow commenting on the left side' do + should_not_allow_commenting(find('.match', match: :first).find(:xpath, '..'), 'left') + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting(find('.match', match: :first).find(:xpath, '..'), 'right') + end + end + + context 'with an unfolded line' do + before(:each) do + find('.js-unfold', match: :first).click + wait_for_ajax + end + + # The first `.js-unfold` unfolds upwards, therefore the first + # `.line_holder` will be an unfolded line. + let(:line_holder) { first('.line_holder[id="1"]') } + + it 'should not allow commenting on the left side' do + should_not_allow_commenting(line_holder, 'left') + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting(line_holder, 'right') + end + end + end + + context 'when hovering over an inline view diff file' do + before do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, view: 'inline') + end + + context 'with a new line' do + it 'should allow commenting' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) + end + end + + context 'with an old line' do + it 'should allow commenting' do + should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]')) + end + end + + context 'with an unchanged line' do + it 'should allow commenting' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]')) + end + end + + context 'with a match line' do + it 'should not allow commenting' do + should_not_allow_commenting(find('.match', match: :first)) + end + end + + context 'with an unfolded line' do + before(:each) do + find('.js-unfold', match: :first).click + wait_for_ajax + end + + # The first `.js-unfold` unfolds upwards, therefore the first + # `.line_holder` will be an unfolded line. + let(:line_holder) { first('.line_holder[id="1"]') } + + it 'should not allow commenting' do + should_not_allow_commenting line_holder + end + end + + context 'when hovering over a diff discussion' do + before do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, view: 'inline') + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]')) + visit namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + end + + it 'should not allow commenting' do + should_not_allow_commenting(find('.line_holder', match: :first)) + end + end + end + + context 'when the MR only supports legacy diff notes' do + before do + @merge_request.merge_request_diff.update_attributes(start_commit_sha: nil) + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, view: 'inline') + end + + context 'with a new line' do + it 'should allow commenting' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) + end + end + + context 'with an old line' do + it 'should allow commenting' do + should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]')) + end + end + + context 'with an unchanged line' do + it 'should allow commenting' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]')) + end + end + + context 'with a match line' do + it 'should not allow commenting' do + should_not_allow_commenting(find('.match', match: :first)) + end + end + end + + def should_allow_commenting(line_holder, diff_side = nil) + line = get_line_components(line_holder, diff_side) + line[:content].hover + expect(line[:num]).to have_css comment_button_class + + comment_on_line(line_holder, line) + + assert_comment_persistence(line_holder) + end + + def should_not_allow_commenting(line_holder, diff_side = nil) + line = get_line_components(line_holder, diff_side) + line[:content].hover + expect(line[:num]).not_to have_css comment_button_class + end + + def get_line_components(line_holder, diff_side = nil) + if diff_side.nil? + get_inline_line_components(line_holder) + else + get_parallel_line_components(line_holder, diff_side) + end + end + + def get_inline_line_components(line_holder) + { content: line_holder.find('.line_content', match: :first), num: line_holder.find('.diff-line-num', match: :first) } + end + + def get_parallel_line_components(line_holder, diff_side = nil) + side_index = diff_side == 'left' ? 0 : 1 + # Wait for `.line_content` + line_holder.find('.line_content', match: :first) + # Wait for `.diff-line-num` + line_holder.find('.diff-line-num', match: :first) + { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } + end + + def comment_on_line(line_holder, line) + line[:num].find(comment_button_class).trigger 'click' + line_holder.find(:xpath, notes_holder_input_xpath) + + notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) + expect(notes_holder_input[:class]).to include(notes_holder_input_class) + + notes_holder_input.fill_in 'note[note]', with: test_note_comment + click_button 'Comment' + wait_for_ajax + end + + def assert_comment_persistence(line_holder) + expect(line_holder).to have_xpath notes_holder_input_xpath + + notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) + expect(notes_holder_saved[:class]).not_to include(notes_holder_input_class) + expect(notes_holder_saved).to have_content test_note_comment + end + end +end diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index 9e007ab7635..c77e719c5df 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -14,8 +14,19 @@ feature 'Edit Merge Request', feature: true do end context 'editing a MR' do - it 'form should have class js-quick-submit' do + it 'has class js-quick-submit in form' do expect(page).to have_selector('.js-quick-submit') end + + it 'warns about version conflict' do + merge_request.update(title: "New title") + + fill_in 'merge_request_title', with: 'bug 345' + fill_in 'merge_request_description', with: 'bug description' + + click_button 'Save changes' + + expect(page).to have_content 'Someone edited the merge request the same time you did' + end end end diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb index e3ecd60a5f3..d917d5950ec 100644 --- a/spec/features/merge_requests/filter_by_milestone_spec.rb +++ b/spec/features/merge_requests/filter_by_milestone_spec.rb @@ -17,11 +17,12 @@ feature 'Merge Request filtering by Milestone', feature: true do visit_merge_requests(project) filter_by_milestone(Milestone::None.title) + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) end context 'filters by upcoming milestone', js: true do - it 'should not show issues with no expiry' do + it 'does not show issues with no expiry' do create(:merge_request, :with_diffs, source_project: project) create(:merge_request, :simple, source_project: project, milestone: milestone) @@ -31,7 +32,7 @@ feature 'Merge Request filtering by Milestone', feature: true do expect(page).to have_css('.merge-request', count: 0) end - it 'should show issues in future' do + it 'shows issues in future' do milestone = create(:milestone, project: project, due_date: Date.tomorrow) create(:merge_request, :with_diffs, source_project: project) create(:merge_request, :simple, source_project: project, milestone: milestone) @@ -39,10 +40,11 @@ feature 'Merge Request filtering by Milestone', feature: true do visit_merge_requests(project) filter_by_milestone(Milestone::Upcoming.title) + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) end - it 'should not show issues in past' do + it 'does not show issues in past' do milestone = create(:milestone, project: project, due_date: Date.yesterday) create(:merge_request, :with_diffs, source_project: project) create(:merge_request, :simple, source_project: project, milestone: milestone) @@ -61,6 +63,7 @@ feature 'Merge Request filtering by Milestone', feature: true do visit_merge_requests(project) filter_by_milestone(milestone.title) + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) end diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb new file mode 100644 index 00000000000..7594cbf54e8 --- /dev/null +++ b/spec/features/merge_requests/form_spec.rb @@ -0,0 +1,273 @@ +require 'rails_helper' + +describe 'New/edit merge request', feature: true, js: true do + let!(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let(:fork_project) { create(:project, forked_from_project: project) } + let!(:user) { create(:user)} + let!(:milestone) { create(:milestone, project: project) } + let!(:label) { create(:label, project: project) } + let!(:label2) { create(:label, project: project) } + + before do + project.team << [user, :master] + end + + context 'owned projects' do + before do + login_as(user) + end + + context 'new merge request' do + before do + visit new_namespace_project_merge_request_path( + project.namespace, + project, + merge_request: { + source_project_id: project.id, + target_project_id: project.id, + source_branch: 'fix', + target_branch: 'master' + }) + end + + it 'creates new merge request' do + click_button 'Assignee' + page.within '.dropdown-menu-user' do + click_link user.name + end + expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user.name + end + + click_button 'Milestone' + page.within '.issue-milestone' do + click_link milestone.title + end + expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + page.within '.js-label-select' do + expect(page).to have_content label.title + end + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + + click_button 'Submit merge request' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end + end + end + end + + context 'edit merge request' do + before do + merge_request = create(:merge_request, + source_project: project, + target_project: project, + source_branch: 'fix', + target_branch: 'master' + ) + + visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'updates merge request' do + click_button 'Assignee' + page.within '.dropdown-menu-user' do + click_link user.name + end + expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user.name + end + + click_button 'Milestone' + page.within '.issue-milestone' do + click_link milestone.title + end + expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + page.within '.js-label-select' do + expect(page).to have_content label.title + end + + click_button 'Save changes' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end + end + end + end + end + + context 'forked project' do + before do + fork_project.team << [user, :master] + login_as(user) + end + + context 'new merge request' do + before do + visit new_namespace_project_merge_request_path( + fork_project.namespace, + fork_project, + merge_request: { + source_project_id: fork_project.id, + target_project_id: project.id, + source_branch: 'fix', + target_branch: 'master' + }) + end + + it 'creates new merge request' do + click_button 'Assignee' + page.within '.dropdown-menu-user' do + click_link user.name + end + expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user.name + end + + click_button 'Milestone' + page.within '.issue-milestone' do + click_link milestone.title + end + expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + page.within '.js-label-select' do + expect(page).to have_content label.title + end + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + + click_button 'Submit merge request' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end + end + end + end + + context 'edit merge request' do + before do + merge_request = create(:merge_request, + source_project: fork_project, + target_project: project, + source_branch: 'fix', + target_branch: 'master' + ) + + visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'should update merge request' do + click_button 'Assignee' + page.within '.dropdown-menu-user' do + click_link user.name + end + expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user.name + end + + click_button 'Milestone' + page.within '.issue-milestone' do + click_link milestone.title + end + expect(find('input[name="merge_request[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + page.within '.js-milestone-select' do + expect(page).to have_content milestone.title + end + + click_button 'Labels' + page.within '.dropdown-menu-labels' do + click_link label.title + click_link label2.title + end + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[1].value).to match(label.id.to_s) + expect(page.all('input[name="merge_request[label_ids][]"]', visible: false)[2].value).to match(label2.id.to_s) + page.within '.js-label-select' do + expect(page).to have_content label.title + end + + click_button 'Save changes' + + page.within '.issuable-sidebar' do + page.within '.assignee' do + expect(page).to have_content user.name + end + + page.within '.milestone' do + expect(page).to have_content milestone.title + end + + page.within '.labels' do + expect(page).to have_content label.title + expect(page).to have_content label2.title + end + end + end + end + end +end diff --git a/spec/features/merge_requests/merge_request_versions_spec.rb b/spec/features/merge_requests/merge_request_versions_spec.rb new file mode 100644 index 00000000000..23cee891bac --- /dev/null +++ b/spec/features/merge_requests/merge_request_versions_spec.rb @@ -0,0 +1,83 @@ +require 'spec_helper' + +feature 'Merge Request versions', js: true, feature: true do + let(:merge_request) { create(:merge_request, importing: true) } + let(:project) { merge_request.source_project } + + before do + login_as :admin + merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') + merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'show the latest version of the diff' do + page.within '.mr-version-dropdown' do + expect(page).to have_content 'latest version' + end + + expect(page).to have_content '8 changed files' + end + + describe 'switch between versions' do + before do + page.within '.mr-version-dropdown' do + find('.btn-default').click + click_link 'version 1' + end + end + + it 'should show older version' do + page.within '.mr-version-dropdown' do + expect(page).to have_content 'version 1' + end + + expect(page).to have_content '5 changed files' + end + + it 'show the message about disabled comments' do + expect(page).to have_content 'Comments are disabled' + end + end + + describe 'compare with older version' do + before do + page.within '.mr-version-compare-dropdown' do + find('.btn-default').click + click_link 'version 1' + end + end + + it 'has a path with comparison context' do + expect(page).to have_current_path diffs_namespace_project_merge_request_path( + project.namespace, + project, + merge_request.iid, + diff_id: 2, + start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' + ) + end + + it 'should have correct value in the compare dropdown' do + page.within '.mr-version-compare-dropdown' do + expect(page).to have_content 'version 1' + end + end + + it 'show the message about disabled comments' do + expect(page).to have_content 'Comments are disabled' + end + + it 'show diff between new and old version' do + expect(page).to have_content '4 changed files with 15 additions and 6 deletions' + end + + it 'should return to latest version when "Show latest version" button is clicked' do + click_link 'Show latest version' + page.within '.mr-version-dropdown' do + expect(page).to have_content 'latest version' + end + expect(page).to have_content '8 changed files' + end + end +end diff --git a/spec/features/merge_requests/merge_when_build_succeeds_spec.rb b/spec/features/merge_requests/merge_when_build_succeeds_spec.rb index 7a110cf987d..b49e5103ac5 100644 --- a/spec/features/merge_requests/merge_when_build_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_build_succeeds_spec.rb @@ -1,19 +1,24 @@ require 'spec_helper' feature 'Merge When Build Succeeds', feature: true, js: true do - let(:user) { create(:user) } - + let(:user) { create(:user) } let(:project) { create(:project, :public) } - let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user) } + let(:merge_request) do + create(:merge_request_with_diffs, source_project: project, author: user, + remove_source_branch: false) + end - before do - project.team << [user, :master] - project.enable_ci + let(:pipeline) do + create(:ci_pipeline, project: project, sha: merge_request.diff_head_sha, + ref: merge_request.source_branch) end - context "Active build for Merge Request" do - let!(:pipeline) { create(:ci_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) } - let!(:ci_build) { create(:ci_build, pipeline: pipeline) } + before { project.team << [user, :master] } + + context 'when there is active build for merge request' do + background do + create(:ci_build, pipeline: pipeline) + end before do login_as user @@ -41,27 +46,28 @@ feature 'Merge When Build Succeeds', feature: true, js: true do end end - context 'When it is enabled' do + context 'merge when build succeeds is enabled' do + let(:merge_request) do + create(:merge_request_with_diffs, :simple, source_project: project, + author: user, merge_user: user, + title: 'MepMep', merge_when_build_succeeds: true, + remove_source_branch: false) + end + let!(:pipeline) { create(:ci_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) } let!(:ci_build) { create(:ci_build, pipeline: pipeline) } before do - # Do not create a new MR here in this scope, this will yield a second MR with the same source branch - merge_request.remove_source_branch = false - merge_request.merge_when_build_succeeds = true - merge_request.merge_user = user - merge_request.save - login_as user visit_merge_request(merge_request) end - it 'cancels the automatic merge' do + it 'allows to cancel the automatic merge' do click_link "Cancel Automatic Merge" expect(page).to have_button "Merge When Build Succeeds" - visit_merge_request(merge_request) # Needed to refresh the page + visit_merge_request(merge_request) # refresh the page expect(page).to have_content "Canceled the automatic merge" end @@ -72,10 +78,21 @@ feature 'Merge When Build Succeeds', feature: true, js: true do expect(page).to have_content "The source branch will be removed" end + + context 'when build succeeds' do + background { build.success } + + it 'merges merge request' do + visit_merge_request(merge_request) # refresh the page + + expect(page).to have_content 'The changes were merged' + expect(merge_request.reload).to be_merged + end + end end - context 'Build is not active' do - it "should not allow for enabling" do + context 'when build is not active' do + it "does not allow to enable merge when build succeeds" do visit_merge_request(merge_request) expect(page).not_to have_link "Merge When Build Succeeds" end diff --git a/spec/features/merge_requests/pipelines_spec.rb b/spec/features/merge_requests/pipelines_spec.rb new file mode 100644 index 00000000000..9c4c0525267 --- /dev/null +++ b/spec/features/merge_requests/pipelines_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +feature 'Pipelines for Merge Requests', feature: true, js: true do + include WaitForAjax + + given(:user) { create(:user) } + given(:merge_request) { create(:merge_request) } + given(:project) { merge_request.target_project } + + before do + project.team << [user, :master] + login_as user + end + + context 'with pipelines' do + let!(:pipeline) do + create(:ci_empty_pipeline, + project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end + + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + scenario 'user visits merge request pipelines tab' do + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + wait_for_ajax + + expect(page).to have_selector('.pipeline-actions') + end + end + + context 'without pipelines' do + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + scenario 'user visits merge request page' do + page.within('.merge-request-tabs') do + expect(page).to have_no_link('Pipelines') + end + end + end +end diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb new file mode 100644 index 00000000000..b56fdfe5611 --- /dev/null +++ b/spec/features/merge_requests/update_merge_requests_spec.rb @@ -0,0 +1,132 @@ +require 'rails_helper' + +feature 'Multiple merge requests updating from merge_requests#index', feature: true do + include WaitForAjax + + let!(:user) { create(:user)} + let!(:project) { create(:project) } + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + + before do + project.team << [user, :master] + login_as(user) + end + + context 'status', js: true do + describe 'close merge request' do + before do + visit namespace_project_merge_requests_path(project.namespace, project) + end + + it 'closes merge request' do + change_status('Closed') + + expect(page).to have_selector('.merge-request', count: 0) + end + end + + describe 'reopen merge request' do + before do + merge_request.close + visit namespace_project_merge_requests_path(project.namespace, project, state: 'closed') + end + + it 'reopens merge request' do + change_status('Open') + + expect(page).to have_selector('.merge-request', count: 0) + end + end + end + + context 'assignee', js: true do + describe 'set assignee' do + before do + visit namespace_project_merge_requests_path(project.namespace, project) + end + + it "updates merge request with assignee" do + change_assignee(user.name) + + page.within('.merge-request .controls') do + expect(find('.author_link')["title"]).to have_content(user.name) + end + end + end + + describe 'remove assignee' do + before do + merge_request.assignee = user + merge_request.save + visit namespace_project_merge_requests_path(project.namespace, project) + end + + it "removes assignee from the merge request" do + change_assignee('Unassigned') + + expect(find('.merge-request .controls')).not_to have_css('.author_link') + end + end + end + + context 'milestone', js: true do + let(:milestone) { create(:milestone, project: project) } + + describe 'set milestone' do + before do + visit namespace_project_merge_requests_path(project.namespace, project) + end + + it "updates merge request with milestone" do + change_milestone(milestone.title) + + expect(find('.merge-request')).to have_content milestone.title + end + end + + describe 'unset milestone' do + before do + merge_request.milestone = milestone + merge_request.save + visit namespace_project_merge_requests_path(project.namespace, project) + end + + it "removes milestone from the merge request" do + change_milestone("No Milestone") + + expect(find('.merge-request')).not_to have_content milestone.title + end + end + end + + def change_status(text) + find('#check_all_issues').click + find('.js-issue-status').click + find('.dropdown-menu-status a', text: text).click + click_update_merge_requests_button + end + + def change_assignee(text) + find('#check_all_issues').click + find('.js-update-assignee').click + wait_for_ajax + + page.within '.dropdown-menu-user' do + click_link text + end + + click_update_merge_requests_button + end + + def change_milestone(text) + find('#check_all_issues').click + find('.issues_bulk_update .js-milestone-select').click + find('.dropdown-menu-milestone a', text: text).click + click_update_merge_requests_button + end + + def click_update_merge_requests_button + find('.update_selected_issues').click + wait_for_ajax + end +end diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb new file mode 100644 index 00000000000..cb3cea3fd51 --- /dev/null +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -0,0 +1,79 @@ +require 'rails_helper' + +feature 'Merge Requests > User uses slash commands', feature: true, js: true do + include SlashCommandsHelpers + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:merge_request) { create(:merge_request, source_project: project) } + let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } + + it_behaves_like 'issuable record that supports slash commands in its description and notes', :merge_request do + let(:issuable) { create(:merge_request, source_project: project) } + let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } } + end + + describe 'merge-request-only commands' do + before do + project.team << [user, :master] + login_with(user) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + after do + wait_for_ajax + end + + describe 'toggling the WIP prefix in the title from note' do + context 'when the current user can toggle the WIP prefix' do + it 'adds the WIP: prefix to the title' do + write_note("/wip") + + expect(page).not_to have_content '/wip' + expect(page).to have_content 'Your commands have been executed!' + + expect(merge_request.reload.work_in_progress?).to eq true + end + + it 'removes the WIP: prefix from the title' do + merge_request.title = merge_request.wip_title + merge_request.save + write_note("/wip") + + expect(page).not_to have_content '/wip' + expect(page).to have_content 'Your commands have been executed!' + + expect(merge_request.reload.work_in_progress?).to eq false + end + end + + context 'when the current user cannot toggle the WIP prefix' do + let(:guest) { create(:user) } + before do + project.team << [guest, :guest] + logout + login_with(guest) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not change the WIP prefix' do + write_note("/wip") + + expect(page).not_to have_content '/wip' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(merge_request.reload.work_in_progress?).to eq false + end + end + end + + describe 'adding a due date from note' do + it 'does not recognize the command nor create a note' do + write_note('/due 2016-08-28') + + expect(page).not_to have_content '/due 2016-08-28' + end + end + end +end diff --git a/spec/features/merge_requests/widget_deployments_spec.rb b/spec/features/merge_requests/widget_deployments_spec.rb new file mode 100644 index 00000000000..8e23ec50d4a --- /dev/null +++ b/spec/features/merge_requests/widget_deployments_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +feature 'Widget Deployments Header', feature: true, js: true do + include WaitForAjax + + describe 'when deployed to an environment' do + let(:project) { merge_request.target_project } + let(:merge_request) { create(:merge_request, :merged) } + let(:environment) { create(:environment, project: project) } + let!(:deployment) do + create(:deployment, environment: environment, sha: project.commit('master').id) + end + + before do + login_as :admin + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'displays that the environment is deployed' do + wait_for_ajax + + expect(page).to have_content("Deployed to #{environment.name}") + expect(find('.ci_widget > span > span')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) + end + end +end diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index c2c7acff3e8..b8c838bf7ab 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -3,9 +3,8 @@ require 'rails_helper' feature 'Milestone', feature: true do include WaitForAjax - let(:project) { create(:project, :public) } + let(:project) { create(:empty_project, :public) } let(:user) { create(:user) } - let(:milestone) { create(:milestone, project: project, title: 8.7) } before do project.team << [user, :master] @@ -13,7 +12,7 @@ feature 'Milestone', feature: true do end feature 'Create a milestone' do - scenario 'should show an informative message for a new issue' do + scenario 'shows an informative message for a new milestone' do visit new_namespace_project_milestone_path(project.namespace, project) page.within '.milestone-form' do fill_in "milestone_title", with: '8.7' @@ -25,11 +24,27 @@ feature 'Milestone', feature: true do end feature 'Open a milestone with closed issues' do - scenario 'should show an informative message' do + scenario 'shows an informative message' do + milestone = create(:milestone, project: project, title: 8.7) + create(:issue, title: "Bugfix1", project: project, milestone: milestone, state: "closed") visit namespace_project_milestone_path(project.namespace, project, milestone) expect(find('.alert-success')).to have_content('All issues for this milestone are closed. You may close this milestone now.') end end + + feature 'Open a milestone with an existing title' do + scenario 'displays validation message' do + milestone = create(:milestone, project: project, title: 8.7) + + visit new_namespace_project_milestone_path(project.namespace, project) + page.within '.milestone-form' do + fill_in "milestone_title", with: milestone.title + end + find('input[name="commit"]').click + + expect(find('.alert-danger')).to have_content('Title has already been taken') + end + end end diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb index 0b38c413f44..5d7247e2a62 100644 --- a/spec/features/notes_on_merge_requests_spec.rb +++ b/spec/features/notes_on_merge_requests_spec.rb @@ -23,7 +23,7 @@ describe 'Comments', feature: true do subject { page } describe 'the note form' do - it 'should be valid' do + it 'is valid' do is_expected.to have_css('.js-main-target-form', visible: true, count: 1) expect(find('.js-main-target-form input[type=submit]').value). to eq('Comment') @@ -39,7 +39,7 @@ describe 'Comments', feature: true do end end - it 'should have enable submit button and preview button' do + it 'has enable submit button and preview button' do page.within('.js-main-target-form') do expect(page).not_to have_css('.js-comment-button[disabled]') expect(page).to have_css('.js-md-preview-button', visible: true) @@ -57,7 +57,7 @@ describe 'Comments', feature: true do end end - it 'should be added and form reset' do + it 'is added and form reset' do is_expected.to have_content('This is awsome!') page.within('.js-main-target-form') do expect(page).to have_no_field('note[note]', with: 'This is awesome!') @@ -70,7 +70,7 @@ describe 'Comments', feature: true do end describe 'when editing a note', js: true do - it 'should contain the hidden edit form' do + it 'contains the hidden edit form' do page.within("#note_#{note.id}") do is_expected.to have_css('.note-edit-form', visible: false) end @@ -82,7 +82,7 @@ describe 'Comments', feature: true do find(".js-note-edit").click end - it 'should show the note edit form and hide the note body' do + it 'shows the note edit form and hide the note body' do page.within("#note_#{note.id}") do expect(find('.current-note-edit-form', visible: true)).to be_visible expect(find('.note-edit-form', visible: true)).to be_visible @@ -141,7 +141,7 @@ describe 'Comments', feature: true do let(:project2) { create(:project, :private) } let(:issue) { create(:issue, project: project2) } let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'markdown') } - let!(:note) { create(:note_on_merge_request, :system, noteable: merge_request, project: project, note: "mentioned in #{issue.to_reference(project)}") } + let!(:note) { create(:note_on_merge_request, :system, noteable: merge_request, project: project, note: "Mentioned in #{issue.to_reference(project)}") } it 'shows the system note' do login_as :admin @@ -234,12 +234,24 @@ describe 'Comments', feature: true do end end - it 'should be added as discussion' do + it 'adds as discussion' do is_expected.to have_content('Another comment on line 10') is_expected.to have_css('.notes_holder') is_expected.to have_css('.notes_holder .note', count: 1) is_expected.to have_button('Reply...') end + + it 'adds code to discussion' do + click_button 'Reply...' + + page.within(first('.js-discussion-note-form')) do + fill_in 'note[note]', with: '```{{ test }}```' + + click_button('Comment') + end + + expect(page).to have_content('{{ test }}') + end end end end diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index c7c00a3266a..a78a1c9c890 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -12,17 +12,17 @@ feature 'Member autocomplete', feature: true do end shared_examples "open suggestions" do - it 'suggestions are displayed' do + it 'displays suggestions' do expect(page).to have_selector('.atwho-view', visible: true) end - it 'author is suggested' do + it 'suggests author' do page.within('.atwho-view', visible: true) do expect(page).to have_content(author.username) end end - it 'participant is suggested' do + it 'suggests participant' do page.within('.atwho-view', visible: true) do expect(page).to have_content(participant.username) end diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index c80253fead8..c3d8c349ca4 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -15,7 +15,7 @@ describe 'Profile account page', feature: true do it { expect(page).to have_content('Remove account') } - it 'should delete the account' do + it 'deletes the account' do expect { click_link 'Delete account' }.to change { User.count }.by(-1) expect(current_path).to eq(new_user_session_path) end @@ -27,7 +27,7 @@ describe 'Profile account page', feature: true do visit profile_account_path end - it 'should not have option to remove account' do + it 'does not have option to remove account' do expect(page).not_to have_content('Remove account') expect(current_path).to eq(profile_account_path) end diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb new file mode 100644 index 00000000000..eb1050d21c6 --- /dev/null +++ b/spec/features/profiles/keys_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +feature 'Profile > SSH Keys', feature: true do + let(:user) { create(:user) } + + before do + login_as(user) + end + + describe 'User adds a key' do + before do + visit profile_keys_path + end + + scenario 'auto-populates the title', js: true do + fill_in('Key', with: attributes_for(:key).fetch(:key)) + + expect(find_field('Title').value).to eq 'dummy@gitlab.com' + end + + scenario 'saves the new key' do + attrs = attributes_for(:key) + + fill_in('Key', with: attrs[:key]) + fill_in('Title', with: attrs[:title]) + click_button('Add key') + + expect(page).to have_content("Title: #{attrs[:title]}") + expect(page).to have_content(attrs[:key]) + end + end + + scenario 'User sees their keys' do + key = create(:key, user: user) + visit profile_keys_path + + expect(page).to have_content(key.title) + end + + scenario 'User removes a key via the key index' do + create(:key, user: user) + visit profile_keys_path + + click_link('Remove') + + expect(page).to have_content('Your SSH keys (0)') + end + + scenario 'User removes a key via its details page' do + key = create(:key, user: user) + visit profile_key_path(key) + + click_link('Remove') + + expect(page).to have_content('Your SSH keys (0)') + end +end diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb new file mode 100644 index 00000000000..4cbdd89d46f --- /dev/null +++ b/spec/features/profiles/password_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe 'Profile > Password', feature: true do + let(:user) { create(:user, password_automatically_set: true) } + + before do + login_as(user) + visit edit_profile_password_path + end + + def fill_passwords(password, confirmation) + fill_in 'New password', with: password + fill_in 'Password confirmation', with: confirmation + + click_button 'Save password' + end + + context 'User with password automatically set' do + describe 'User puts different passwords in the field and in the confirmation' do + it 'shows an error message' do + fill_passwords('mypassword', 'mypassword2') + + page.within('.alert-danger') do + expect(page).to have_content("Password confirmation doesn't match Password") + end + end + + it 'does not contains the current password field after an error' do + fill_passwords('mypassword', 'mypassword2') + + expect(page).to have_no_field('user[current_password]') + end + end + + describe 'User puts the same passwords in the field and in the confirmation' do + it 'shows a success message' do + fill_passwords('mypassword', 'mypassword') + + page.within('.flash-notice') do + expect(page).to have_content('Password was successfully updated. Please login with it') + end + end + end + end +end diff --git a/spec/features/profiles/preferences_spec.rb b/spec/features/profiles/preferences_spec.rb index 787bf42d048..d14a1158b67 100644 --- a/spec/features/profiles/preferences_spec.rb +++ b/spec/features/profiles/preferences_spec.rb @@ -68,10 +68,14 @@ describe 'Profile > Preferences', feature: true do allowing_for_delay do find('#logo').click + + expect(page).to have_content("You don't have starred projects yet") expect(page.current_path).to eq starred_dashboard_projects_path end click_link 'Your Projects' + + expect(page).not_to have_content("You don't have starred projects yet") expect(page.current_path).to eq dashboard_projects_path end end diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb new file mode 100644 index 00000000000..01a95bf49ac --- /dev/null +++ b/spec/features/projects/badges/coverage_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +feature 'test coverage badge' do + given!(:user) { create(:user) } + given!(:project) { create(:project, :private) } + + context 'when user has access to view badge' do + background do + project.team << [user, :developer] + login_as(user) + end + + scenario 'user requests coverage badge image for pipeline' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 100, name: 'test:1') + create_build(pipeline, coverage: 90, name: 'test:2') + end + + show_test_coverage_badge + + expect_coverage_badge('95%') + end + + scenario 'user requests coverage badge for specific job' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 50, name: 'test:1') + create_build(pipeline, coverage: 50, name: 'test:2') + create_build(pipeline, coverage: 85, name: 'coverage') + end + + show_test_coverage_badge(job: 'coverage') + + expect_coverage_badge('85%') + end + + scenario 'user requests coverage badge for pipeline without coverage' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: nil, name: 'test') + end + + show_test_coverage_badge + + expect_coverage_badge('unknown') + end + end + + context 'when user does not have access to view badge' do + background { login_as(user) } + + scenario 'user requests test coverage badge image' do + show_test_coverage_badge + + expect(page).to have_http_status(404) + end + end + + def create_pipeline + opts = { project: project, ref: 'master', sha: project.commit.id } + + create(:ci_pipeline, opts).tap do |pipeline| + yield pipeline + pipeline.update_status + end + end + + def create_build(pipeline, coverage:, name:) + opts = { pipeline: pipeline, coverage: coverage, name: name } + + create(:ci_build, :success, opts) + end + + def show_test_coverage_badge(job: nil) + visit coverage_namespace_project_badges_path( + project.namespace, project, ref: :master, job: job, format: :svg) + end + + def expect_coverage_badge(coverage) + svg = Nokogiri::XML.parse(page.body) + expect(page.response_headers['Content-Type']).to include('image/svg+xml') + expect(svg.at(%Q{text:contains("#{coverage}")})).to be_truthy + end +end diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb index 75166bca119..67a4a5d1ab1 100644 --- a/spec/features/projects/badges/list_spec.rb +++ b/spec/features/projects/badges/list_spec.rb @@ -9,25 +9,43 @@ feature 'list of badges' do visit namespace_project_pipelines_settings_path(project.namespace, project) end - scenario 'user displays list of badges' do - expect(page).to have_content 'build status' - expect(page).to have_content 'Markdown' - expect(page).to have_content 'HTML' - expect(page).to have_css('.highlight', count: 2) - expect(page).to have_xpath("//img[@alt='build status']") - - page.within('.highlight', match: :first) do - expect(page).to have_content 'badges/master/build.svg' + scenario 'user wants to see build status badge' do + page.within('.build-status') do + expect(page).to have_content 'build status' + expect(page).to have_content 'Markdown' + expect(page).to have_content 'HTML' + expect(page).to have_css('.highlight', count: 2) + expect(page).to have_xpath("//img[@alt='build status']") + + page.within('.highlight', match: :first) do + expect(page).to have_content 'badges/master/build.svg' + end end end - scenario 'user changes current ref on badges list page', js: true do - first('.js-project-refs-dropdown').click + scenario 'user wants to see coverage report badge' do + page.within('.coverage-report') do + expect(page).to have_content 'coverage report' + expect(page).to have_content 'Markdown' + expect(page).to have_content 'HTML' + expect(page).to have_css('.highlight', count: 2) + expect(page).to have_xpath("//img[@alt='coverage report']") - page.within '.project-refs-form' do - click_link 'improve/awesome' + page.within('.highlight', match: :first) do + expect(page).to have_content 'badges/master/coverage.svg' + end end + end + + scenario 'user changes current ref of build status badge', js: true do + page.within('.build-status') do + first('.js-project-refs-dropdown').click - expect(page).to have_content 'badges/improve/awesome/build.svg' + page.within '.project-refs-form' do + click_link 'improve/awesome' + end + + expect(page).to have_content 'badges/improve/awesome/build.svg' + end end end diff --git a/spec/features/projects/branches/delete_spec.rb b/spec/features/projects/branches/delete_spec.rb new file mode 100644 index 00000000000..63878c55421 --- /dev/null +++ b/spec/features/projects/branches/delete_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +feature 'Delete branch', feature: true, js: true do + include WaitForAjax + + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + login_as user + visit namespace_project_branches_path(project.namespace, project) + end + + it 'destroys tooltip' do + first('.remove-row').hover + expect(page).to have_selector('.tooltip') + + first('.remove-row').click + wait_for_ajax + + expect(page).not_to have_selector('.tooltip') + end +end diff --git a/spec/features/projects/branches/download_buttons_spec.rb b/spec/features/projects/branches/download_buttons_spec.rb new file mode 100644 index 00000000000..92028c19361 --- /dev/null +++ b/spec/features/projects/branches/download_buttons_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +feature 'Download buttons in branches page', feature: true do + given(:user) { create(:user) } + given(:role) { :developer } + given(:status) { 'success' } + given(:project) { create(:project) } + + given(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit('binary-encoding').sha, + ref: 'binary-encoding', # make sure the branch is in the 1st page! + status: status) + end + + given!(:build) do + create(:ci_build, :success, :artifacts, + pipeline: pipeline, + status: pipeline.status, + name: 'build') + end + + background do + login_as(user) + project.team << [user, role] + end + + describe 'when checking branches' do + context 'with artifacts' do + before do + visit namespace_project_branches_path(project.namespace, project) + end + + scenario 'shows download artifacts button' do + href = latest_succeeded_namespace_project_artifacts_path( + project.namespace, project, 'binary-encoding/download', + job: 'build') + + expect(page).to have_link "Download '#{build.name}'", href: href + end + end + end +end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 79abba21854..d26a0caf036 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -1,32 +1,46 @@ require 'spec_helper' describe 'Branches', feature: true do - let(:project) { create(:project) } + let(:project) { create(:project, :public) } let(:repository) { project.repository } - before do - login_as :user - project.team << [@user, :developer] - end + context 'logged in' do + before do + login_as :user + project.team << [@user, :developer] + end - describe 'Initial branches page' do - it 'shows all the branches' do - visit namespace_project_branches_path(project.namespace, project) + describe 'Initial branches page' do + it 'shows all the branches' do + visit namespace_project_branches_path(project.namespace, project) - repository.branches { |branch| expect(page).to have_content("#{branch.name}") } - expect(page).to have_content("Protected branches can be managed in project settings") + repository.branches { |branch| expect(page).to have_content("#{branch.name}") } + expect(page).to have_content("Protected branches can be managed in project settings") + end end - end - describe 'Find branches' do - it 'shows filtered branches', js: true do - visit namespace_project_branches_path(project.namespace, project, project.id) + describe 'Find branches' do + it 'shows filtered branches', js: true do + visit namespace_project_branches_path(project.namespace, project) + + fill_in 'branch-search', with: 'fix' + find('#branch-search').native.send_keys(:enter) - fill_in 'branch-search', with: 'fix' - find('#branch-search').native.send_keys(:enter) + expect(page).to have_content('fix') + expect(find('.all-branches')).to have_selector('li', count: 1) + end + end + end + + context 'logged out' do + before do + visit namespace_project_branches_path(project.namespace, project) + end - expect(page).to have_content('fix') - expect(find('.all-branches')).to have_selector('li', count: 1) + it 'does not show merge request button' do + page.within first('.all-branches li') do + expect(page).not_to have_content 'Merge Request' + end end end end diff --git a/spec/features/builds_spec.rb b/spec/features/projects/builds_spec.rb index 0cfeb2e57d8..d1685f95503 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/projects/builds_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'tempfile' describe "Builds" do let(:artifacts_file) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') } @@ -6,7 +7,7 @@ describe "Builds" do before do login_as(:user) @commit = FactoryGirl.create :ci_pipeline - @build = FactoryGirl.create :ci_build, pipeline: @commit + @build = FactoryGirl.create :ci_build, :trace, pipeline: @commit @build2 = FactoryGirl.create :ci_build @project = @commit.project @project.team << [@user, :developer] @@ -156,7 +157,6 @@ describe "Builds" do context 'Build raw trace' do before do @build.run! - @build.trace = 'BUILD TRACE' visit namespace_project_build_path(@project.namespace, @project, @build) end @@ -164,6 +164,26 @@ describe "Builds" do expect(page).to have_link 'Raw' end end + + describe 'Variables' do + before do + @trigger_request = create :ci_trigger_request_with_variables + @build = create :ci_build, pipeline: @commit, trigger_request: @trigger_request + visit namespace_project_build_path(@project.namespace, @project, @build) + end + + it 'shows variable key and value after click', js: true do + expect(page).to have_css('.reveal-variables') + expect(page).not_to have_css('.js-build-variable') + expect(page).not_to have_css('.js-build-value') + + click_button 'Reveal Variables' + + expect(page).not_to have_css('.reveal-variables') + expect(page).to have_selector('.js-build-variable', text: 'TRIGGER_KEY_1') + expect(page).to have_selector('.js-build-value', text: 'TRIGGER_VALUE_1') + end + end end describe "POST /:project/builds/:id/cancel" do @@ -255,35 +275,101 @@ describe "Builds" do end end - describe "GET /:project/builds/:id/raw" do - context "Build from project" do - before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - @build.run! - @build.trace = 'BUILD TRACE' - visit namespace_project_build_path(@project.namespace, @project, @build) - page.within('.js-build-sidebar') { click_link 'Raw' } + describe 'GET /:project/builds/:id/raw' do + context 'access source' do + context 'build from project' do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + page.within('.js-build-sidebar') { click_link 'Raw' } + end + + it 'sends the right headers' do + expect(page.status_code).to eq(200) + expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(page.response_headers['X-Sendfile']).to eq(@build.path_to_trace) + end end - it 'sends the right headers' do - expect(page.status_code).to eq(200) - expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(page.response_headers['X-Sendfile']).to eq(@build.path_to_trace) + context 'build from other project' do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build2.run! + visit raw_namespace_project_build_path(@project.namespace, @project, @build2) + end + + it 'sends the right headers' do + expect(page.status_code).to eq(404) + end end end - context "Build from other project" do - before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - @build2.run! - @build2.trace = 'BUILD TRACE' - visit raw_namespace_project_build_path(@project.namespace, @project, @build2) - puts page.status_code - puts current_url + context 'storage form' do + let(:existing_file) { Tempfile.new('existing-trace-file').path } + let(:non_existing_file) do + file = Tempfile.new('non-existing-trace-file') + path = file.path + file.unlink + path end - it 'sends the right headers' do - expect(page.status_code).to eq(404) + context 'when build has trace in file' do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + + allow_any_instance_of(Project).to receive(:ci_id).and_return(nil) + allow_any_instance_of(Ci::Build).to receive(:path_to_trace).and_return(existing_file) + allow_any_instance_of(Ci::Build).to receive(:old_path_to_trace).and_return(non_existing_file) + + page.within('.js-build-sidebar') { click_link 'Raw' } + end + + it 'sends the right headers' do + expect(page.status_code).to eq(200) + expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(page.response_headers['X-Sendfile']).to eq(existing_file) + end + end + + context 'when build has trace in old file' do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + + allow_any_instance_of(Project).to receive(:ci_id).and_return(999) + allow_any_instance_of(Ci::Build).to receive(:path_to_trace).and_return(non_existing_file) + allow_any_instance_of(Ci::Build).to receive(:old_path_to_trace).and_return(existing_file) + + page.within('.js-build-sidebar') { click_link 'Raw' } + end + + it 'sends the right headers' do + expect(page.status_code).to eq(200) + expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(page.response_headers['X-Sendfile']).to eq(existing_file) + end + end + + context 'when build has trace in DB' do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + + allow_any_instance_of(Project).to receive(:ci_id).and_return(nil) + allow_any_instance_of(Ci::Build).to receive(:path_to_trace).and_return(non_existing_file) + allow_any_instance_of(Ci::Build).to receive(:old_path_to_trace).and_return(non_existing_file) + + page.within('.js-build-sidebar') { click_link 'Raw' } + end + + it 'sends the right headers' do + expect(page.status_code).to eq(404) + end end end end diff --git a/spec/features/projects/commits/cherry_pick_spec.rb b/spec/features/projects/commits/cherry_pick_spec.rb index 1b4ff6b6f1b..e45e3a36d01 100644 --- a/spec/features/projects/commits/cherry_pick_spec.rb +++ b/spec/features/projects/commits/cherry_pick_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +include WaitForAjax describe 'Cherry-pick Commits' do let(:project) { create(:project) } @@ -8,12 +9,11 @@ describe 'Cherry-pick Commits' do before do login_as :user project.team << [@user, :master] - visit namespace_project_commits_path(project.namespace, project, project.repository.root_ref, { limit: 5 }) + visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) end context "I cherry-pick a commit" do it do - visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) find("a[href='#modal-cherry-pick-commit']").click expect(page).not_to have_content('v1.0.0') # Only branches, not tags page.within('#modal-cherry-pick-commit') do @@ -26,7 +26,6 @@ describe 'Cherry-pick Commits' do context "I cherry-pick a merge commit" do it do - visit namespace_project_commit_path(project.namespace, project, master_pickable_merge.id) find("a[href='#modal-cherry-pick-commit']").click page.within('#modal-cherry-pick-commit') do uncheck 'create_merge_request' @@ -38,7 +37,6 @@ describe 'Cherry-pick Commits' do context "I cherry-pick a commit that was previously cherry-picked" do it do - visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) find("a[href='#modal-cherry-pick-commit']").click page.within('#modal-cherry-pick-commit') do uncheck 'create_merge_request' @@ -56,7 +54,6 @@ describe 'Cherry-pick Commits' do context "I cherry-pick a commit in a new merge request" do it do - visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) find("a[href='#modal-cherry-pick-commit']").click page.within('#modal-cherry-pick-commit') do click_button 'Cherry-pick' @@ -64,4 +61,28 @@ describe 'Cherry-pick Commits' do expect(page).to have_content('The commit has been successfully cherry-picked. You can now submit a merge request to get this change into the original branch.') end end + + context "I cherry-pick a commit from a different branch", js: true do + it do + find('.commit-action-buttons a.dropdown-toggle').click + find(:css, "a[href='#modal-cherry-pick-commit']").click + + page.within('#modal-cherry-pick-commit') do + click_button 'master' + end + + wait_for_ajax + + page.within('#modal-cherry-pick-commit .dropdown-menu .dropdown-content') do + click_link 'feature' + end + + page.within('#modal-cherry-pick-commit') do + uncheck 'create_merge_request' + click_button 'Cherry-pick' + end + + expect(page).to have_content('The commit has been successfully cherry-picked.') + end + end end diff --git a/spec/features/projects/edit_spec.rb b/spec/features/projects/edit_spec.rb new file mode 100644 index 00000000000..a1643fd1f43 --- /dev/null +++ b/spec/features/projects/edit_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +feature 'Project edit', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project) } + + before do + project.team << [user, :master] + login_as(user) + + visit edit_namespace_project_path(project.namespace, project) + end + + context 'feature visibility' do + context 'merge requests select' do + it 'hides merge requests section' do + select('Disabled', from: 'project_project_feature_attributes_merge_requests_access_level') + + expect(page).to have_selector('.merge-requests-feature', visible: false) + end + + it 'hides merge requests section after save' do + select('Disabled', from: 'project_project_feature_attributes_merge_requests_access_level') + + expect(page).to have_selector('.merge-requests-feature', visible: false) + + click_button 'Save changes' + + wait_for_ajax + + expect(page).to have_selector('.merge-requests-feature', visible: false) + end + end + + context 'builds select' do + it 'hides merge requests section' do + select('Disabled', from: 'project_project_feature_attributes_builds_access_level') + + expect(page).to have_selector('.builds-feature', visible: false) + end + + it 'hides merge requests section after save' do + select('Disabled', from: 'project_project_feature_attributes_builds_access_level') + + expect(page).to have_selector('.builds-feature', visible: false) + + click_button 'Save changes' + + wait_for_ajax + + expect(page).to have_selector('.builds-feature', visible: false) + end + end + end +end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb new file mode 100644 index 00000000000..9b487e350f2 --- /dev/null +++ b/spec/features/projects/features_visibility_spec.rb @@ -0,0 +1,122 @@ +require 'spec_helper' +include WaitForAjax + +describe 'Edit Project Settings', feature: true do + let(:member) { create(:user) } + let!(:project) { create(:project, :public, path: 'gitlab', name: 'sample') } + let(:non_member) { create(:user) } + + describe 'project features visibility selectors', js: true do + before do + project.team << [member, :master] + login_as(member) + end + + tools = { builds: "pipelines", issues: "issues", wiki: "wiki", snippets: "snippets", merge_requests: "merge_requests" } + + tools.each do |tool_name, shortcut_name| + describe "feature #{tool_name}" do + it 'toggles visibility' do + visit edit_namespace_project_path(project.namespace, project) + + select 'Disabled', from: "project_project_feature_attributes_#{tool_name}_access_level" + click_button 'Save changes' + wait_for_ajax + expect(page).not_to have_selector(".shortcuts-#{shortcut_name}") + + select 'Everyone with access', from: "project_project_feature_attributes_#{tool_name}_access_level" + click_button 'Save changes' + wait_for_ajax + expect(page).to have_selector(".shortcuts-#{shortcut_name}") + + select 'Only team members', from: "project_project_feature_attributes_#{tool_name}_access_level" + click_button 'Save changes' + wait_for_ajax + expect(page).to have_selector(".shortcuts-#{shortcut_name}") + + sleep 0.1 + end + end + end + end + + describe 'project features visibility pages' do + before do + @tools = + { + builds: namespace_project_pipelines_path(project.namespace, project), + issues: namespace_project_issues_path(project.namespace, project), + wiki: namespace_project_wiki_path(project.namespace, project, :home), + snippets: namespace_project_snippets_path(project.namespace, project), + merge_requests: namespace_project_merge_requests_path(project.namespace, project), + } + end + + context 'normal user' do + it 'renders 200 if tool is enabled' do + @tools.each do |method_name, url| + project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::ENABLED) + visit url + expect(page.status_code).to eq(200) + end + end + + it 'renders 404 if feature is disabled' do + @tools.each do |method_name, url| + project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED) + visit url + expect(page.status_code).to eq(404) + end + end + + it 'renders 404 if feature is enabled only for team members' do + project.team.truncate + + @tools.each do |method_name, url| + project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) + visit url + expect(page.status_code).to eq(404) + end + end + + it 'renders 200 if users is member of group' do + group = create(:group) + project.group = group + project.save + + group.add_owner(member) + + @tools.each do |method_name, url| + project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) + visit url + expect(page.status_code).to eq(200) + end + end + end + + context 'admin user' do + before do + non_member.update_attribute(:admin, true) + login_as(non_member) + end + + it 'renders 404 if feature is disabled' do + @tools.each do |method_name, url| + project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED) + visit url + expect(page.status_code).to eq(404) + end + end + + it 'renders 200 if feature is enabled only for team members' do + project.team.truncate + + @tools.each do |method_name, url| + project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) + visit url + expect(page.status_code).to eq(200) + end + end + end + end +end diff --git a/spec/features/projects/files/download_buttons_spec.rb b/spec/features/projects/files/download_buttons_spec.rb new file mode 100644 index 00000000000..d7c29a7e074 --- /dev/null +++ b/spec/features/projects/files/download_buttons_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +feature 'Download buttons in files tree', feature: true do + given(:user) { create(:user) } + given(:role) { :developer } + given(:status) { 'success' } + given(:project) { create(:project) } + + given(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: status) + end + + given!(:build) do + create(:ci_build, :success, :artifacts, + pipeline: pipeline, + status: pipeline.status, + name: 'build') + end + + background do + login_as(user) + project.team << [user, role] + end + + describe 'when files tree' do + context 'with artifacts' do + before do + visit namespace_project_tree_path( + project.namespace, project, project.default_branch) + end + + scenario 'shows download artifacts button' do + href = latest_succeeded_namespace_project_artifacts_path( + project.namespace, project, "#{project.default_branch}/download", + job: 'build') + + expect(page).to have_link "Download '#{build.name}'", href: href + end + end + end +end diff --git a/spec/features/projects/files/edit_file_soft_wrap_spec.rb b/spec/features/projects/files/edit_file_soft_wrap_spec.rb new file mode 100644 index 00000000000..012befa7990 --- /dev/null +++ b/spec/features/projects/files/edit_file_soft_wrap_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +feature 'User uses soft wrap whilst editing file', feature: true, js: true do + before do + user = create(:user) + project = create(:project) + project.team << [user, :master] + login_as user + visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: 'test_file-name') + editor = find('.file-editor.code') + editor.click + editor.send_keys 'Touch water with paw then recoil in horror chase dog then + run away chase the pig around the house eat owner\'s food, and knock + dish off table head butt cant eat out of my own dish. Cat is love, cat + is life rub face on everything poop on grasses so meow. Playing with + balls of wool flee in terror at cucumber discovered on floor run in + circles tuxedo cats always looking dapper, but attack dog, run away + and pretend to be victim so all of a sudden cat goes crazy, yet chase + laser. Make muffins sit in window and stare ooo, a bird! yum lick yarn + hanging out of own butt jump off balcony, onto stranger\'s head yet + chase laser. Purr for no reason stare at ceiling hola te quiero.'.squish + end + + let(:toggle_button) { find('.soft-wrap-toggle') } + + scenario 'user clicks the "Soft wrap" button and then "No wrap" button' do + wrapped_content_width = get_content_width + toggle_button.click + expect(toggle_button).to have_content 'No wrap' + unwrapped_content_width = get_content_width + expect(unwrapped_content_width).to be < wrapped_content_width + + toggle_button.click + expect(toggle_button).to have_content 'Soft wrap' + expect(get_content_width).to be > unwrapped_content_width + end + + def get_content_width + find('.ace_content')[:style].slice!(/width: \d+/).slice!(/\d+/) + end +end diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb new file mode 100644 index 00000000000..fe047e00409 --- /dev/null +++ b/spec/features/projects/files/editing_a_file_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +feature 'User wants to edit a file', feature: true do + include WaitForAjax + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:commit_params) do + { + source_branch: project.default_branch, + target_branch: project.default_branch, + commit_message: "Committing First Update", + file_path: ".gitignore", + file_content: "First Update", + last_commit_sha: Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, + ".gitignore").sha + } + end + + background do + project.team << [user, :master] + login_as user + visit namespace_project_edit_blob_path(project.namespace, project, + File.join(project.default_branch, '.gitignore')) + end + + scenario 'file has been updated since the user opened the edit page' do + Files::UpdateService.new(project, user, commit_params).execute + + click_button 'Commit Changes' + + expect(page).to have_content 'Someone edited the file the same time you did.' + end +end diff --git a/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb b/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb new file mode 100644 index 00000000000..10b91d8990b --- /dev/null +++ b/spec/features/projects/files/files_sort_submodules_with_folders_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +feature 'User views files page', feature: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:forked_project_with_submodules) } + + before do + project.team << [user, :master] + login_as user + visit namespace_project_tree_path(project.namespace, project, project.repository.root_ref) + end + + scenario 'user sees folders and submodules sorted together, followed by files' do + rows = all('td.tree-item-file-name').map(&:text) + tree = project.repository.tree + + folders = tree.trees.map(&:name) + files = tree.blobs.map(&:name) + submodules = tree.submodules.map do |submodule| + submodule.name + " @ " + submodule.id[0..7] + end + + sorted_titles = (folders + submodules).sort + files + + expect(rows).to eq(sorted_titles) + end +end diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index e1e105e6bbe..a521ce50f35 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -23,7 +23,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") @@ -39,6 +39,7 @@ feature 'project owner creates a license file', feature: true, js: true do scenario 'project master creates a license file from the "Add license" link' do click_link 'Add License' + expect(page).to have_content('New File') expect(current_path).to eq( namespace_project_new_blob_path(project.namespace, project, 'master')) expect(find('#file_name').value).to eq('LICENSE') @@ -46,7 +47,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 67aac25e427..4453b6d485f 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -14,6 +14,7 @@ feature 'project owner sees a link to create a license file in empty project', f visit namespace_project_path(project.namespace, project) click_link 'Create empty bare repository' click_on 'LICENSE' + expect(page).to have_content('New File') expect(current_path).to eq( namespace_project_new_blob_path(project.namespace, project, 'master')) @@ -22,7 +23,7 @@ feature 'project owner sees a link to create a license file in empty project', f select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") diff --git a/spec/features/projects/gfm_autocomplete_load_spec.rb b/spec/features/projects/gfm_autocomplete_load_spec.rb new file mode 100644 index 00000000000..1921ea6d8ae --- /dev/null +++ b/spec/features/projects/gfm_autocomplete_load_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe 'GFM autocomplete loading', feature: true, js: true do + let(:project) { create(:project) } + + before do + login_as :admin + + visit namespace_project_path(project.namespace, project) + end + + it 'does not load on project#show' do + expect(evaluate_script('GitLab.GfmAutoComplete.dataSource')).to eq('') + end + + it 'loads on new issue page' do + visit new_namespace_project_issue_path(project.namespace, project) + + expect(evaluate_script('GitLab.GfmAutoComplete.dataSource')).not_to eq('') + end +end diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb new file mode 100644 index 00000000000..1a71a03fbd9 --- /dev/null +++ b/spec/features/projects/group_links_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +feature 'Project group links', feature: true, js: true do + include Select2Helper + + let(:master) { create(:user) } + let(:project) { create(:project) } + let!(:group) { create(:group) } + + background do + project.team << [master, :master] + login_as(master) + end + + context 'setting an expiration date for a group link' do + before do + visit namespace_project_group_links_path(project.namespace, project) + + select2 group.id, from: '#link_group_id' + fill_in 'expires_at', with: (Time.current + 4.5.days).strftime('%Y-%m-%d') + page.find('body').click + click_on 'Share' + end + + it 'shows the expiration time with a warning class' do + page.within('.enabled-groups') do + expect(page).to have_content('expires in 4 days') + expect(page).to have_selector('.text-warning') + end + end + end +end diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb new file mode 100644 index 00000000000..c22441f8929 --- /dev/null +++ b/spec/features/projects/guest_navigation_menu_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe "Guest navigation menu" do + let(:project) { create :empty_project, :private } + let(:guest) { create :user } + + before do + project.team << [guest, :guest] + + login_as(guest) + end + + it "shows allowed tabs only" do + visit namespace_project_path(project.namespace, project) + + within(".nav-links") do + expect(page).to have_content 'Project' + expect(page).to have_content 'Activity' + expect(page).to have_content 'Issues' + expect(page).to have_content 'Wiki' + + expect(page).not_to have_content 'Repository' + expect(page).not_to have_content 'Pipelines' + expect(page).not_to have_content 'Graphs' + expect(page).not_to have_content 'Merge Requests' + end + end +end diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb new file mode 100644 index 00000000000..52d08982c7a --- /dev/null +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +# Integration test that exports a file using the Import/Export feature +# It looks up for any sensitive word inside the JSON, so if a sensitive word is found +# we''l have to either include it adding the model that includes it to the +safe_list+ +# or make sure the attribute is blacklisted in the +import_export.yml+ configuration +feature 'Import/Export - project export integration test', feature: true, js: true do + include Select2Helper + include ExportFileHelper + + let(:user) { create(:admin) } + let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } + let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys } + + let(:sensitive_words) { %w[pass secret token key] } + let(:safe_list) do + { + token: [ProjectHook, Ci::Trigger, CommitStatus], + key: [Project, Ci::Variable, :yaml_variables] + } + end + let(:safe_hashes) { { yaml_variables: %w[key value public] } } + + let(:project) { setup_project } + + background do + allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + end + + after do + FileUtils.rm_rf(export_path, secure: true) + end + + context 'admin user' do + before do + login_as(user) + end + + scenario 'exports a project successfully' do + visit edit_namespace_project_path(project.namespace, project) + + expect(page).to have_content('Export project') + + click_link 'Export project' + + visit edit_namespace_project_path(project.namespace, project) + + expect(page).to have_content('Download export') + + expect(file_permissions(project.export_path)).to eq(0700) + + in_directory_with_expanded_export(project) do |exit_status, tmpdir| + expect(exit_status).to eq(0) + + project_json_path = File.join(tmpdir, 'project.json') + expect(File).to exist(project_json_path) + + project_hash = JSON.parse(IO.read(project_json_path)) + + sensitive_words.each do |sensitive_word| + found = find_sensitive_attributes(sensitive_word, project_hash) + + expect(found).to be_nil, failure_message(found.try(:key_found), found.try(:parent), sensitive_word) + end + end + end + + def failure_message(key_found, parent, sensitive_word) + <<-MSG + Found a new sensitive word <#{key_found}>, which is part of the hash #{parent.inspect} + + If you think this information shouldn't get exported, please exclude the model or attribute in IMPORT_EXPORT_CONFIG. + + Otherwise, please add the exception to +safe_list+ in CURRENT_SPEC using #{sensitive_word} as the key and the + correspondent hash or model as the value. + + IMPORT_EXPORT_CONFIG: #{Gitlab::ImportExport.config_file} + CURRENT_SPEC: #{__FILE__} + MSG + end + end +end diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 7835e1678ad..f32834801a0 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -1,10 +1,11 @@ require 'spec_helper' -feature 'project import', feature: true, js: true do +feature 'Import/Export - project import integration test', feature: true, js: true do include Select2Helper - let(:user) { create(:admin) } - let!(:namespace) { create(:namespace, name: "asd", owner: user) } + let(:admin) { create(:admin) } + let(:normal_user) { create(:user) } + let!(:namespace) { create(:namespace, name: "asd", owner: admin) } let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') } let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } let(:project) { Project.last } @@ -12,66 +13,87 @@ feature 'project import', feature: true, js: true do background do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) - login_as(user) end after(:each) do FileUtils.rm_rf(export_path, secure: true) end - scenario 'user imports an exported project successfully' do - expect(Project.all.count).to be_zero + context 'admin user' do + before do + login_as(admin) + end - visit new_project_path + scenario 'user imports an exported project successfully' do + expect(Project.all.count).to be_zero - select2('2', from: '#project_namespace_id') - fill_in :project_path, with: 'test-project-path', visible: true - click_link 'GitLab export' + visit new_project_path - expect(page).to have_content('GitLab project export') - expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path') + select2('2', from: '#project_namespace_id') + fill_in :project_path, with: 'test-project-path', visible: true + click_link 'GitLab export' - attach_file('file', file) + expect(page).to have_content('GitLab project export') + expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path') - click_on 'Import project' # import starts + attach_file('file', file) - expect(project).not_to be_nil - expect(project.issues).not_to be_empty - expect(project.merge_requests).not_to be_empty - expect(project_hook).to exist - expect(wiki_exists?).to be true - expect(project.import_status).to eq('finished') - end + click_on 'Import project' # import starts + + expect(project).not_to be_nil + expect(project.issues).not_to be_empty + expect(project.merge_requests).not_to be_empty + expect(project_hook).to exist + expect(wiki_exists?).to be true + expect(project.import_status).to eq('finished') + end - scenario 'invalid project' do - project = create(:project, namespace_id: 2) + scenario 'invalid project' do + project = create(:project, namespace_id: 2) - visit new_project_path + visit new_project_path - select2('2', from: '#project_namespace_id') - fill_in :project_path, with: project.name, visible: true - click_link 'GitLab export' + select2('2', from: '#project_namespace_id') + fill_in :project_path, with: project.name, visible: true + click_link 'GitLab export' - attach_file('file', file) - click_on 'Import project' + attach_file('file', file) + click_on 'Import project' - page.within('.flash-container') do - expect(page).to have_content('Project could not be imported') + page.within('.flash-container') do + expect(page).to have_content('Project could not be imported') + end + end + + scenario 'project with no name' do + create(:project, namespace_id: 2) + + visit new_project_path + + select2('2', from: '#project_namespace_id') + + # click on disabled element + find(:link, 'GitLab export').trigger('click') + + page.within('.flash-container') do + expect(page).to have_content('Please enter path and name') + end end end - scenario 'project with no name' do - create(:project, namespace_id: 2) + context 'normal user' do + before do + login_as(normal_user) + end - visit new_project_path + scenario 'non-admin user is allowed to import a project' do + expect(Project.all.count).to be_zero - select2('2', from: '#project_namespace_id') + visit new_project_path - # click on disabled element - find(:link, 'GitLab export').trigger('click') + fill_in :project_path, with: 'test-project-path', visible: true - page.within('.flash-container') do - expect(page).to have_content('Please enter path and name') + expect(page).to have_content('GitLab export') end end diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz Binary files differindex 7bb0d26b21c..d04bdea0fe4 100644 --- a/spec/features/projects/import_export/test_project_export.tar.gz +++ b/spec/features/projects/import_export/test_project_export.tar.gz diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb new file mode 100644 index 00000000000..d886909ce85 --- /dev/null +++ b/spec/features/projects/issuable_templates_spec.rb @@ -0,0 +1,152 @@ +require 'spec_helper' + +feature 'issuable templates', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_as user + end + + context 'user creates an issue using templates' do + let(:template_content) { 'this is a test "bug" template' } + let(:longtemplate_content) { %Q(this\n\n\n\n\nis\n\n\n\n\na\n\n\n\n\nbug\n\n\n\n\ntemplate) } + let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:description_addition) { ' appending to description' } + + background do + project.repository.commit_file(user, '.gitlab/issue_templates/bug.md', template_content, 'added issue template', 'master', false) + project.repository.commit_file(user, '.gitlab/issue_templates/test.md', longtemplate_content, 'added issue template', 'master', false) + visit edit_namespace_project_issue_path project.namespace, project, issue + fill_in :'issue[title]', with: 'test issue title' + end + + scenario 'user selects "bug" template' do + select_template 'bug' + wait_for_ajax + preview_template + save_changes + end + + scenario 'user selects "bug" template and then "no template"' do + select_template 'bug' + wait_for_ajax + select_option 'No template' + wait_for_ajax + preview_template('') + save_changes('') + end + + scenario 'user selects "bug" template, edits description and then selects "reset template"' do + select_template 'bug' + wait_for_ajax + find_field('issue_description').send_keys(description_addition) + preview_template(template_content + description_addition) + select_option 'Reset template' + preview_template + save_changes + end + + it 'updates height of markdown textarea' do + start_height = page.evaluate_script('$(".markdown-area").outerHeight()') + + select_template 'test' + wait_for_ajax + + end_height = page.evaluate_script('$(".markdown-area").outerHeight()') + + expect(end_height).not_to eq(start_height) + end + end + + context 'user creates an issue using templates, with a prior description' do + let(:prior_description) { 'test issue description' } + let(:template_content) { 'this is a test "bug" template' } + let(:issue) { create(:issue, author: user, assignee: user, project: project) } + + background do + project.repository.commit_file(user, '.gitlab/issue_templates/bug.md', template_content, 'added issue template', 'master', false) + visit edit_namespace_project_issue_path project.namespace, project, issue + fill_in :'issue[title]', with: 'test issue title' + fill_in :'issue[description]', with: prior_description + end + + scenario 'user selects "bug" template' do + select_template 'bug' + wait_for_ajax + preview_template("#{prior_description}\n\n#{template_content}") + save_changes + end + end + + context 'user creates a merge request using templates' do + let(:template_content) { 'this is a test "feature-proposal" template' } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) } + + background do + project.repository.commit_file(user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false) + visit edit_namespace_project_merge_request_path project.namespace, project, merge_request + fill_in :'merge_request[title]', with: 'test merge request title' + end + + scenario 'user selects "feature-proposal" template' do + select_template 'feature-proposal' + wait_for_ajax + preview_template + save_changes + end + end + + context 'user creates a merge request from a forked project using templates' do + let(:template_content) { 'this is a test "feature-proposal" template' } + let(:fork_user) { create(:user) } + let(:fork_project) { create(:project, :public) } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: fork_project, target_project: project) } + + background do + logout + project.team << [fork_user, :developer] + fork_project.team << [fork_user, :master] + create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project) + login_as fork_user + project.repository.commit_file(fork_user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false) + visit edit_namespace_project_merge_request_path project.namespace, project, merge_request + fill_in :'merge_request[title]', with: 'test merge request title' + end + + context 'feature proposal template' do + context 'template exists in target project' do + scenario 'user selects template' do + select_template 'feature-proposal' + wait_for_ajax + preview_template + save_changes + end + end + end + end + + def preview_template(expected_content = template_content) + click_link 'Preview' + expect(page).to have_content expected_content + click_link 'Write' + end + + def save_changes(expected_content = template_content) + click_button "Save changes" + expect(page).to have_content expected_content + end + + def select_template(name) + first('.js-issuable-selector').click + first('.js-issuable-selector-wrap .dropdown-content a', text: name).click + end + + def select_option(name) + first('.js-issuable-selector').click + first('.js-issuable-selector-wrap .dropdown-footer-list a', text: name).click + end +end diff --git a/spec/features/projects/issues/list_spec.rb b/spec/features/projects/issues/list_spec.rb new file mode 100644 index 00000000000..3137af074ca --- /dev/null +++ b/spec/features/projects/issues/list_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +feature 'Issues List' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + background do + project.team << [user, :developer] + + login_as(user) + end + + scenario 'user does not see create new list button' do + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + + expect(page).not_to have_selector('.js-new-board-list') + end +end diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 98ba93b4036..cb7495da8eb 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -87,7 +87,7 @@ feature 'Prioritize labels', feature: true do end context 'as a guest' do - it 'can not prioritize labels' do + it 'does not prioritize labels' do user = create(:user) guest = create(:user) project = create(:project, name: 'test', namespace: user.namespace) @@ -102,7 +102,7 @@ feature 'Prioritize labels', feature: true do end context 'as a non signed in user' do - it 'can not prioritize labels' do + it 'does not prioritize labels' do user = create(:user) project = create(:project, name: 'test', namespace: user.namespace) diff --git a/spec/features/projects/main/download_buttons_spec.rb b/spec/features/projects/main/download_buttons_spec.rb new file mode 100644 index 00000000000..227ccf9459c --- /dev/null +++ b/spec/features/projects/main/download_buttons_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +feature 'Download buttons in project main page', feature: true do + given(:user) { create(:user) } + given(:role) { :developer } + given(:status) { 'success' } + given(:project) { create(:project) } + + given(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: status) + end + + given!(:build) do + create(:ci_build, :success, :artifacts, + pipeline: pipeline, + status: pipeline.status, + name: 'build') + end + + background do + login_as(user) + project.team << [user, role] + end + + describe 'when checking project main page' do + context 'with artifacts' do + before do + visit namespace_project_path(project.namespace, project) + end + + scenario 'shows download artifacts button' do + href = latest_succeeded_namespace_project_artifacts_path( + project.namespace, project, "#{project.default_branch}/download", + job: 'build') + + expect(page).to have_link "Download '#{build.name}'", href: href + end + end + end +end diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb new file mode 100644 index 00000000000..430c384ac2e --- /dev/null +++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +feature 'Projects > Members > Master adds member with expiration date', feature: true, js: true do + include Select2Helper + include ActiveSupport::Testing::TimeHelpers + + let(:master) { create(:user) } + let(:project) { create(:project) } + let!(:new_member) { create(:user) } + + background do + project.team << [master, :master] + login_as(master) + end + + scenario 'expiration date is displayed in the members list' do + travel_to Time.zone.parse('2016-08-06 08:00') do + visit namespace_project_project_members_path(project.namespace, project) + + page.within '.users-project-form' do + select2(new_member.id, from: '#user_ids', multiple: true) + fill_in 'expires_at', with: '2016-08-10' + click_on 'Add users to project' + end + + page.within '.project_member:first-child' do + expect(page).to have_content('Expires in 4 days') + end + end + end + + scenario 'change expiration date' do + travel_to Time.zone.parse('2016-08-06 08:00') do + project.team.add_users([new_member.id], :developer, expires_at: '2016-09-06') + visit namespace_project_project_members_path(project.namespace, project) + + page.within '.project_member:first-child' do + click_on 'Edit' + fill_in 'Access expiration date', with: '2016-08-09' + click_on 'Save' + expect(page).to have_content('Expires in 3 days') + end + end + end +end diff --git a/spec/features/projects/members/owner_cannot_leave_project_spec.rb b/spec/features/projects/members/owner_cannot_leave_project_spec.rb index 67811b1048e..6e948b7a616 100644 --- a/spec/features/projects/members/owner_cannot_leave_project_spec.rb +++ b/spec/features/projects/members/owner_cannot_leave_project_spec.rb @@ -1,12 +1,10 @@ require 'spec_helper' feature 'Projects > Members > Owner cannot leave project', feature: true do - let(:owner) { create(:user) } let(:project) { create(:project) } background do - project.team << [owner, :owner] - login_as(owner) + login_as(project.owner) visit namespace_project_path(project.namespace, project) end diff --git a/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb b/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb index 0e54c4fdf20..4ca9272b9c1 100644 --- a/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb +++ b/spec/features/projects/members/owner_cannot_request_access_to_his_project_spec.rb @@ -1,12 +1,10 @@ require 'spec_helper' feature 'Projects > Members > Owner cannot request access to his project', feature: true do - let(:owner) { create(:user) } let(:project) { create(:project) } background do - project.team << [owner, :owner] - login_as(owner) + login_as(project.owner) visit namespace_project_path(project.namespace, project) end diff --git a/spec/features/projects/merge_requests/list_spec.rb b/spec/features/projects/merge_requests/list_spec.rb new file mode 100644 index 00000000000..5dd58ad66a7 --- /dev/null +++ b/spec/features/projects/merge_requests/list_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +feature 'Merge Requests List' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + background do + project.team << [user, :developer] + + login_as(user) + end + + scenario 'user does not see create new list button' do + create(:merge_request, source_project: project) + + visit namespace_project_merge_requests_path(project.namespace, project) + + expect(page).not_to have_selector('.js-new-board-list') + end +end diff --git a/spec/features/pipelines_spec.rb b/spec/features/projects/pipelines_spec.rb index 377a9aba60d..47482bc3cc9 100644 --- a/spec/features/pipelines_spec.rb +++ b/spec/features/projects/pipelines_spec.rb @@ -12,7 +12,7 @@ describe "Pipelines" do end describe 'GET /:project/pipelines' do - let!(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', status: 'running') } + let!(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running') } [:all, :running, :branches].each do |scope| context "displaying #{scope}" do @@ -31,9 +31,12 @@ describe "Pipelines" do end context 'cancelable pipeline' do - let!(:running) { create(:ci_build, :running, pipeline: pipeline, stage: 'test', commands: 'test') } + let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } - before { visit namespace_project_pipelines_path(project.namespace, project) } + before do + build.run + visit namespace_project_pipelines_path(project.namespace, project) + end it { expect(page).to have_link('Cancel') } it { expect(page).to have_selector('.ci-running') } @@ -47,9 +50,12 @@ describe "Pipelines" do end context 'retryable pipelines' do - let!(:failed) { create(:ci_build, :failed, pipeline: pipeline, stage: 'test', commands: 'test') } + let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } - before { visit namespace_project_pipelines_path(project.namespace, project) } + before do + build.drop + visit namespace_project_pipelines_path(project.namespace, project) + end it { expect(page).to have_link('Retry') } it { expect(page).to have_selector('.ci-failed') } @@ -58,7 +64,7 @@ describe "Pipelines" do before { click_link('Retry') } it { expect(page).not_to have_link('Retry') } - it { expect(page).to have_selector('.ci-pending') } + it { expect(page).to have_selector('.ci-running') } end end @@ -80,27 +86,32 @@ describe "Pipelines" do context 'when running' do let!(:running) { create(:generic_commit_status, status: 'running', pipeline: pipeline, stage: 'test') } - before { visit namespace_project_pipelines_path(project.namespace, project) } + before do + visit namespace_project_pipelines_path(project.namespace, project) + end - it 'not be cancelable' do + it 'is not cancelable' do expect(page).not_to have_link('Cancel') end - it 'pipeline is running' do + it 'has pipeline running' do expect(page).to have_selector('.ci-running') end end context 'when failed' do - let!(:running) { create(:generic_commit_status, status: 'failed', pipeline: pipeline, stage: 'test') } + let!(:status) { create(:generic_commit_status, :pending, pipeline: pipeline, stage: 'test') } - before { visit namespace_project_pipelines_path(project.namespace, project) } + before do + status.drop + visit namespace_project_pipelines_path(project.namespace, project) + end - it 'not be retryable' do + it 'is not retryable' do expect(page).not_to have_link('Retry') end - it 'pipeline is failed' do + it 'has failed pipeline' do expect(page).to have_selector('.ci-failed') end end @@ -147,7 +158,7 @@ describe "Pipelines" do before { visit namespace_project_pipeline_path(project.namespace, project, pipeline) } - it 'showing a list of builds' do + it 'shows a list of builds' do expect(page).to have_content('Test') expect(page).to have_content(@success.id) expect(page).to have_content('Deploy') @@ -182,7 +193,11 @@ describe "Pipelines" do end context 'playing manual build' do - before { click_link('Play') } + before do + within '.pipeline-holder' do + click_link('Play') + end + end it { expect(@manual.reload).to be_pending } end @@ -194,7 +209,7 @@ describe "Pipelines" do before { visit new_namespace_project_pipeline_path(project.namespace, project) } context 'for valid commit' do - before { fill_in('Create for', with: 'master') } + before { fill_in('pipeline[ref]', with: 'master') } context 'with gitlab-ci.yml' do before { stub_ci_pipeline_to_return_yaml_file } @@ -211,11 +226,37 @@ describe "Pipelines" do context 'for invalid commit' do before do - fill_in('Create for', with: 'invalid reference') + fill_in('pipeline[ref]', with: 'invalid-reference') click_on 'Create pipeline' end it { expect(page).to have_content('Reference not found') } end end + + describe 'Create pipelines', feature: true do + let(:project) { create(:project) } + + before do + visit new_namespace_project_pipeline_path(project.namespace, project) + end + + describe 'new pipeline page' do + it 'has field to add a new pipeline' do + expect(page).to have_field('pipeline[ref]') + expect(page).to have_content('Create for') + end + end + + describe 'find pipelines' do + it 'shows filtered pipelines', js: true do + fill_in('pipeline[ref]', with: 'fix') + find('input#ref').native.send_keys(:keydown) + + within('.ui-autocomplete') do + expect(page).to have_selector('li', text: 'fix') + end + end + end + end end diff --git a/spec/features/projects/ref_switcher_spec.rb b/spec/features/projects/ref_switcher_spec.rb new file mode 100644 index 00000000000..b3ba40b35af --- /dev/null +++ b/spec/features/projects/ref_switcher_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +feature 'Ref switcher', feature: true, js: true do + include WaitForAjax + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_as(user) + visit namespace_project_tree_path(project.namespace, project, 'master') + end + + it 'allow user to change ref by enter key' do + click_button 'master' + wait_for_ajax + + page.within '.project-refs-form' do + input = find('input[type="search"]') + input.set 'expand' + + input.native.send_keys :down + input.native.send_keys :down + input.native.send_keys :enter + + expect(page).to have_content 'expand-collapse-files' + end + end +end diff --git a/spec/features/projects/snippets_spec.rb b/spec/features/projects/snippets_spec.rb new file mode 100644 index 00000000000..d37e8ed4699 --- /dev/null +++ b/spec/features/projects/snippets_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe 'Project snippets', feature: true do + context 'when the project has snippets' do + let(:project) { create(:empty_project, :public) } + let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) } + before do + allow(Snippet).to receive(:default_per_page).and_return(1) + visit namespace_project_snippets_path(project.namespace, project) + end + + it_behaves_like 'paginated snippets' + end +end diff --git a/spec/features/projects/tags/download_buttons_spec.rb b/spec/features/projects/tags/download_buttons_spec.rb new file mode 100644 index 00000000000..dd93d25c2c6 --- /dev/null +++ b/spec/features/projects/tags/download_buttons_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +feature 'Download buttons in tags page', feature: true do + given(:user) { create(:user) } + given(:role) { :developer } + given(:status) { 'success' } + given(:tag) { 'v1.0.0' } + given(:project) { create(:project) } + + given(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit(tag).sha, + ref: tag, + status: status) + end + + given!(:build) do + create(:ci_build, :success, :artifacts, + pipeline: pipeline, + status: pipeline.status, + name: 'build') + end + + background do + login_as(user) + project.team << [user, role] + end + + describe 'when checking tags' do + context 'with artifacts' do + before do + visit namespace_project_tags_path(project.namespace, project) + end + + scenario 'shows download artifacts button' do + href = latest_succeeded_namespace_project_artifacts_path( + project.namespace, project, "#{tag}/download", + job: 'build') + + expect(page).to have_link "Download '#{build.name}'", href: href + end + end + end +end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 6fa8298d489..c30d38b6508 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -44,7 +44,7 @@ feature 'Project', feature: true do visit edit_namespace_project_path(project.namespace, project) end - it 'should remove fork' do + it 'removes fork' do expect(page).to have_content 'Remove fork relationship' remove_with_confirm('Remove fork relationship', project.path) @@ -57,7 +57,7 @@ feature 'Project', feature: true do describe 'removal', js: true do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, namespace: user.namespace, name: 'project1') } before do login_with(user) @@ -65,8 +65,12 @@ feature 'Project', feature: true do visit edit_namespace_project_path(project.namespace, project) end - it 'should remove project' do + it 'removes a project' do expect { remove_with_confirm('Remove project', project.path) }.to change {Project.count}.by(-1) + expect(page).to have_content "Project 'project1' will be deleted." + expect(Project.all.count).to be_zero + expect(project.issues).to be_empty + expect(project.merge_requests).to be_empty end end @@ -78,11 +82,11 @@ feature 'Project', feature: true do before do login_with(user) - project.team.add_user(user, Gitlab::Access::MASTER) + project.add_user(user, Gitlab::Access::MASTER) visit namespace_project_path(project.namespace, project) end - it 'click toggle and show dropdown', js: true do + it 'clicks toggle and shows dropdown', js: true do find('.js-projects-dropdown-toggle').click expect(page).to have_css('.dropdown-menu-projects .dropdown-content li', count: 1) end @@ -97,12 +101,12 @@ feature 'Project', feature: true do context 'on issues page', js: true do before do login_with(user) - project.team.add_user(user, Gitlab::Access::MASTER) - project2.team.add_user(user, Gitlab::Access::MASTER) + project.add_user(user, Gitlab::Access::MASTER) + project2.add_user(user, Gitlab::Access::MASTER) visit namespace_project_issue_path(project.namespace, project, issue) end - it 'click toggle and show dropdown' do + it 'clicks toggle and shows dropdown' do find('.js-projects-dropdown-toggle').click expect(page).to have_css('.dropdown-menu-projects .dropdown-content li', count: 2) @@ -115,6 +119,35 @@ feature 'Project', feature: true do end end + describe 'tree view (default view is set to Files)' do + let(:user) { create(:user, project_view: 'files') } + let(:project) { create(:forked_project_with_submodules) } + + before do + project.team << [user, :master] + login_as user + visit namespace_project_path(project.namespace, project) + end + + it 'has working links to files' do + click_link('PROCESS.md') + + expect(page.status_code).to eq(200) + end + + it 'has working links to directories' do + click_link('encoding') + + expect(page.status_code).to eq(200) + end + + it 'has working links to submodules' do + click_link('645f6c4c') + + expect(page.status_code).to eq(200) + end + end + def remove_with_confirm(button_text, confirm_with) click_button button_text fill_in 'confirm_name_input', with: confirm_with diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/features/protected_branches/access_control_ce_spec.rb new file mode 100644 index 00000000000..395c61a4743 --- /dev/null +++ b/spec/features/protected_branches/access_control_ce_spec.rb @@ -0,0 +1,71 @@ +RSpec.shared_examples "protected branches > access control > CE" do + ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| + it "allows creating protected branches that #{access_type_name} can push to" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + within('.new_protected_branch') do + allowed_to_push_button = find(".js-allowed-to-push") + + unless allowed_to_push_button.text == access_type_name + allowed_to_push_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end + end + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id]) + end + + it "allows updating protected branches so that #{access_type_name} can push to them" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + + within(".protected-branches-list") do + find(".js-allowed-to-push").click + within('.js-allowed-to-push-container') { click_on access_type_name } + end + + wait_for_ajax + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id) + end + end + + ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| + it "allows creating protected branches that #{access_type_name} can merge to" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + within('.new_protected_branch') do + allowed_to_merge_button = find(".js-allowed-to-merge") + + unless allowed_to_merge_button.text == access_type_name + allowed_to_merge_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end + end + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id]) + end + + it "allows updating protected branches so that #{access_type_name} can merge to them" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + + within(".protected-branches-list") do + find(".js-allowed-to-merge").click + within('.js-allowed-to-merge-container') { click_on access_type_name } + end + + wait_for_ajax + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id) + end + end +end diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 57734b33a44..1a3f7b970f6 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f } feature 'Projected Branches', feature: true, js: true do include WaitForAjax @@ -11,7 +12,7 @@ feature 'Projected Branches', feature: true, js: true do def set_protected_branch_name(branch_name) find(".js-protected-branch-select").click find(".dropdown-input-field").set(branch_name) - click_on "Create Protected Branch: #{branch_name}" + click_on("Create wildcard #{branch_name}") end describe "explicit protected branches" do @@ -71,7 +72,10 @@ feature 'Projected Branches', feature: true, js: true do project.repository.add_branch(user, 'production-stable', 'master') project.repository.add_branch(user, 'staging-stable', 'master') project.repository.add_branch(user, 'development', 'master') - create(:protected_branch, project: project, name: "*-stable") + + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('*-stable') + click_on "Protect" visit namespace_project_protected_branches_path(project.namespace, project) click_on "2 matching branches" @@ -85,66 +89,6 @@ feature 'Projected Branches', feature: true, js: true do end describe "access control" do - ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| - it "allows creating protected branches that #{access_type_name} can push to" do - visit namespace_project_protected_branches_path(project.namespace, project) - set_protected_branch_name('master') - within('.new_protected_branch') do - find(".allowed-to-push").click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } - end - click_on "Protect" - - expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) - end - - it "allows updating protected branches so that #{access_type_name} can push to them" do - visit namespace_project_protected_branches_path(project.namespace, project) - set_protected_branch_name('master') - click_on "Protect" - - expect(ProtectedBranch.count).to eq(1) - - within(".protected-branches-list") do - find(".allowed-to-push").click - within('.dropdown-menu.push') { click_on access_type_name } - end - - wait_for_ajax - expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) - end - end - - ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| - it "allows creating protected branches that #{access_type_name} can merge to" do - visit namespace_project_protected_branches_path(project.namespace, project) - set_protected_branch_name('master') - within('.new_protected_branch') do - find(".allowed-to-merge").click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } - end - click_on "Protect" - - expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) - end - - it "allows updating protected branches so that #{access_type_name} can merge to them" do - visit namespace_project_protected_branches_path(project.namespace, project) - set_protected_branch_name('master') - click_on "Protect" - - expect(ProtectedBranch.count).to eq(1) - - within(".protected-branches-list") do - find(".allowed-to-merge").click - within('.dropdown-menu.merge') { click_on access_type_name } - end - - wait_for_ajax - expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) - end - end + include_examples "protected branches > access control > CE" end end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index a5ed3595b0a..0e1cc9a0f73 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -60,7 +60,7 @@ describe "Runners" do it "removes specific runner for project if this is last project for that runners" do within ".activated-specific-runners" do - click_on "Remove runner" + click_on "Remove Runner" end expect(Ci::Runner.exists?(id: @specific_runner)).to be_falsey @@ -75,7 +75,7 @@ describe "Runners" do end it "enables shared runners" do - click_on "Enable shared runners" + click_on "Enable shared Runners" expect(@project.reload.shared_runners_enabled).to be_truthy end end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 09f70cd3b00..1806200c82c 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe "Search", feature: true do + include WaitForAjax + let(:user) { create(:user) } let(:project) { create(:project, namespace: user.namespace) } let!(:issue) { create(:issue, project: project, assignee: user) } @@ -12,10 +14,40 @@ describe "Search", feature: true do visit search_path end - it 'top right search form is not present' do + it 'does not show top right search form' do expect(page).not_to have_selector('.search') end + context 'search filters', js: true do + let(:group) { create(:group) } + + before do + group.add_owner(user) + end + + it 'shows group name after filtering' do + find('.js-search-group-dropdown').click + wait_for_ajax + + page.within '.search-holder' do + click_link group.name + end + + expect(find('.js-search-group-dropdown')).to have_content(group.name) + end + + it 'shows project name after filtering' do + page.within('.project-filter') do + find('.js-search-project-dropdown').click + wait_for_ajax + + click_link project.name_with_namespace + end + + expect(find('.js-search-project-dropdown')).to have_content(project.name_with_namespace) + end + end + describe 'searching for Projects' do it 'finds a project' do page.within '.search-holder' do @@ -71,21 +103,31 @@ describe "Search", feature: true do end describe 'Right header search field', feature: true do + it 'allows enter key to search', js: true do + visit namespace_project_path(project.namespace, project) + fill_in 'search', with: 'gitlab' + find('#search').native.send_keys(:enter) + + page.within '.title' do + expect(page).to have_content 'Search' + end + end + describe 'Search in project page' do before do visit namespace_project_path(project.namespace, project) end - it 'top right search form is present' do + it 'shows top right search form' do expect(page).to have_selector('#search') end - it 'top right search form contains location badge' do + it 'contains location badge in top right search form' do expect(page).to have_selector('.has-location-badge') end context 'clicking the search field', js: true do - it 'should show category search dropdown' do + it 'shows category search dropdown' do page.find('#search').click expect(page).to have_selector('.dropdown-header', text: /#{project.name}/i) @@ -97,7 +139,7 @@ describe "Search", feature: true do page.find('#search').click end - it 'should take user to her issues page when issues assigned is clicked' do + it 'takes user to her issues page when issues assigned is clicked' do find('.dropdown-menu').click_link 'Issues assigned to me' sleep 2 @@ -105,7 +147,7 @@ describe "Search", feature: true do expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) end - it 'should take user to her issues page when issues authored is clicked' do + it 'takes user to her issues page when issues authored is clicked' do find('.dropdown-menu').click_link "Issues I've created" sleep 2 @@ -113,7 +155,7 @@ describe "Search", feature: true do expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) end - it 'should take user to her MR page when MR assigned is clicked' do + it 'takes user to her MR page when MR assigned is clicked' do find('.dropdown-menu').click_link 'Merge requests assigned to me' sleep 2 @@ -121,7 +163,7 @@ describe "Search", feature: true do expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) end - it 'should take user to her MR page when MR authored is clicked' do + it 'takes user to her MR page when MR authored is clicked' do find('.dropdown-menu').click_link "Merge requests I've created" sleep 2 @@ -137,7 +179,7 @@ describe "Search", feature: true do end end - it 'should not display the category search dropdown' do + it 'does not display the category search dropdown' do expect(page).not_to have_selector('.dropdown-header', text: /#{project.name}/i) end end diff --git a/spec/features/security/dashboard_access_spec.rb b/spec/features/security/dashboard_access_spec.rb index 788581a26cb..40f773956d1 100644 --- a/spec/features/security/dashboard_access_spec.rb +++ b/spec/features/security/dashboard_access_spec.rb @@ -43,6 +43,20 @@ describe "Dashboard access", feature: true do it { is_expected.to be_allowed_for :visitor } end + describe "GET /koding" do + subject { koding_path } + + context 'with Koding enabled' do + before do + stub_application_setting(koding_enabled?: true) + end + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :visitor } + end + end + describe "GET /projects/new" do it { expect(new_project_path).to be_allowed_for :admin } it { expect(new_project_path).to be_allowed_for :user } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index ccb5c06dab0..79417c769a8 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -203,7 +203,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for master } it { is_expected.to be_allowed_for developer } it { is_expected.to be_allowed_for reporter } - it { is_expected.to be_allowed_for guest } + it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } diff --git a/spec/features/snippets_spec.rb b/spec/features/snippets_spec.rb new file mode 100644 index 00000000000..70b16bfc810 --- /dev/null +++ b/spec/features/snippets_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe 'Snippets', feature: true do + context 'when the project has snippets' do + let(:project) { create(:empty_project, :public) } + let!(:snippets) { create_list(:project_snippet, 2, :public, author: project.owner, project: project) } + before do + allow(Snippet).to receive(:default_per_page).and_return(1) + visit snippets_path(username: project.owner.username) + end + + it_behaves_like 'paginated snippets' + end +end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 6ed279ef9be..abb27c90e0a 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -20,6 +20,22 @@ feature 'Task Lists', feature: true do MARKDOWN end + let(:singleIncompleteMarkdown) do + <<-MARKDOWN.strip_heredoc + This is a task list: + + - [ ] Incomplete entry 1 + MARKDOWN + end + + let(:singleCompleteMarkdown) do + <<-MARKDOWN.strip_heredoc + This is a task list: + + - [x] Incomplete entry 1 + MARKDOWN + end + before do Warden.test_mode! @@ -34,77 +50,145 @@ feature 'Task Lists', feature: true do end describe 'for Issues' do - let!(:issue) { create(:issue, description: markdown, author: user, project: project) } + describe 'multiple tasks' do + let!(:issue) { create(:issue, description: markdown, author: user, project: project) } - it 'renders' do - visit_issue(project, issue) + it 'renders' do + visit_issue(project, issue) - expect(page).to have_selector('ul.task-list', count: 1) - expect(page).to have_selector('li.task-list-item', count: 6) - expect(page).to have_selector('ul input[checked]', count: 2) - end + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 6) + expect(page).to have_selector('ul input[checked]', count: 2) + end + + it 'contains the required selectors' do + visit_issue(project, issue) + + container = '.detail-page-description .description.js-task-list-container' - it 'contains the required selectors' do - visit_issue(project, issue) + expect(page).to have_selector(container) + expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") + expect(page).to have_selector("#{container} .js-task-list-field") + expect(page).to have_selector('form.js-issuable-update') + expect(page).to have_selector('a.btn-close') + end - container = '.detail-page-description .description.js-task-list-container' + it 'is only editable by author' do + visit_issue(project, issue) + expect(page).to have_selector('.js-task-list-container') - expect(page).to have_selector(container) - expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") - expect(page).to have_selector("#{container} .js-task-list-field") - expect(page).to have_selector('form.js-issuable-update') - expect(page).to have_selector('a.btn-close') + logout(:user) + + login_as(user2) + visit current_path + expect(page).not_to have_selector('.js-task-list-container') + end + + it 'provides a summary on Issues#index' do + visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("2 of 6 tasks completed") + end end - it 'is only editable by author' do - visit_issue(project, issue) - expect(page).to have_selector('.js-task-list-container') + describe 'single incomplete task' do + let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } - logout(:user) + it 'renders' do + visit_issue(project, issue) - login_as(user2) - visit current_path - expect(page).not_to have_selector('.js-task-list-container') + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 0) + end + + it 'provides a summary on Issues#index' do + visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("0 of 1 task completed") + end end - it 'provides a summary on Issues#index' do - visit namespace_project_issues_path(project.namespace, project) - expect(page).to have_content("6 tasks (2 completed, 4 remaining)") + describe 'single complete task' do + let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } + + it 'renders' do + visit_issue(project, issue) + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 1) + end + + it 'provides a summary on Issues#index' do + visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("1 of 1 task completed") + end end end describe 'for Notes' do let!(:issue) { create(:issue, author: user, project: project) } - let!(:note) do - create(:note, note: markdown, noteable: issue, - project: project, author: user) + describe 'multiple tasks' do + let!(:note) do + create(:note, note: markdown, noteable: issue, + project: project, author: user) + end + + it 'renders for note body' do + visit_issue(project, issue) + + expect(page).to have_selector('.note ul.task-list', count: 1) + expect(page).to have_selector('.note li.task-list-item', count: 6) + expect(page).to have_selector('.note ul input[checked]', count: 2) + end + + it 'contains the required selectors' do + visit_issue(project, issue) + + expect(page).to have_selector('.note .js-task-list-container') + expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox') + expect(page).to have_selector('.note .js-task-list-container .js-task-list-field') + end + + it 'is only editable by author' do + visit_issue(project, issue) + expect(page).to have_selector('.js-task-list-container') + + logout(:user) + + login_as(user2) + visit current_path + expect(page).not_to have_selector('.js-task-list-container') + end end - it 'renders for note body' do - visit_issue(project, issue) - - expect(page).to have_selector('.note ul.task-list', count: 1) - expect(page).to have_selector('.note li.task-list-item', count: 6) - expect(page).to have_selector('.note ul input[checked]', count: 2) - end + describe 'single incomplete task' do + let!(:note) do + create(:note, note: singleIncompleteMarkdown, noteable: issue, + project: project, author: user) + end - it 'contains the required selectors' do - visit_issue(project, issue) + it 'renders for note body' do + visit_issue(project, issue) - expect(page).to have_selector('.note .js-task-list-container') - expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox') - expect(page).to have_selector('.note .js-task-list-container .js-task-list-field') + expect(page).to have_selector('.note ul.task-list', count: 1) + expect(page).to have_selector('.note li.task-list-item', count: 1) + expect(page).to have_selector('.note ul input[checked]', count: 0) + end end - it 'is only editable by author' do - visit_issue(project, issue) - expect(page).to have_selector('.js-task-list-container') + describe 'single complete task' do + let!(:note) do + create(:note, note: singleCompleteMarkdown, noteable: issue, + project: project, author: user) + end - logout(:user) + it 'renders for note body' do + visit_issue(project, issue) - login_as(user2) - visit current_path - expect(page).not_to have_selector('.js-task-list-container') + expect(page).to have_selector('.note ul.task-list', count: 1) + expect(page).to have_selector('.note li.task-list-item', count: 1) + expect(page).to have_selector('.note ul input[checked]', count: 1) + end end end @@ -113,42 +197,78 @@ feature 'Task Lists', feature: true do visit namespace_project_merge_request_path(project.namespace, project, merge) end - let!(:merge) { create(:merge_request, :simple, description: markdown, author: user, source_project: project) } + describe 'multiple tasks' do + let!(:merge) { create(:merge_request, :simple, description: markdown, author: user, source_project: project) } - it 'renders for description' do - visit_merge_request(project, merge) + it 'renders for description' do + visit_merge_request(project, merge) - expect(page).to have_selector('ul.task-list', count: 1) - expect(page).to have_selector('li.task-list-item', count: 6) - expect(page).to have_selector('ul input[checked]', count: 2) - end + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 6) + expect(page).to have_selector('ul input[checked]', count: 2) + end - it 'contains the required selectors' do - visit_merge_request(project, merge) + it 'contains the required selectors' do + visit_merge_request(project, merge) - container = '.detail-page-description .description.js-task-list-container' + container = '.detail-page-description .description.js-task-list-container' - expect(page).to have_selector(container) - expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") - expect(page).to have_selector("#{container} .js-task-list-field") - expect(page).to have_selector('form.js-issuable-update') - expect(page).to have_selector('a.btn-close') - end + expect(page).to have_selector(container) + expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") + expect(page).to have_selector("#{container} .js-task-list-field") + expect(page).to have_selector('form.js-issuable-update') + expect(page).to have_selector('a.btn-close') + end - it 'is only editable by author' do - visit_merge_request(project, merge) - expect(page).to have_selector('.js-task-list-container') + it 'is only editable by author' do + visit_merge_request(project, merge) + expect(page).to have_selector('.js-task-list-container') - logout(:user) + logout(:user) - login_as(user2) - visit current_path - expect(page).not_to have_selector('.js-task-list-container') + login_as(user2) + visit current_path + expect(page).not_to have_selector('.js-task-list-container') + end + + it 'provides a summary on MergeRequests#index' do + visit namespace_project_merge_requests_path(project.namespace, project) + expect(page).to have_content("2 of 6 tasks completed") + end + end + + describe 'single incomplete task' do + let!(:merge) { create(:merge_request, :simple, description: singleIncompleteMarkdown, author: user, source_project: project) } + + it 'renders for description' do + visit_merge_request(project, merge) + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 0) + end + + it 'provides a summary on MergeRequests#index' do + visit namespace_project_merge_requests_path(project.namespace, project) + expect(page).to have_content("0 of 1 task completed") + end end - it 'provides a summary on MergeRequests#index' do - visit namespace_project_merge_requests_path(project.namespace, project) - expect(page).to have_content("6 tasks (2 completed, 4 remaining)") + describe 'single complete task' do + let!(:merge) { create(:merge_request, :simple, description: singleCompleteMarkdown, author: user, source_project: project) } + + it 'renders for description' do + visit_merge_request(project, merge) + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 1) + end + + it 'provides a summary on MergeRequests#index' do + visit namespace_project_merge_requests_path(project.namespace, project) + expect(page).to have_content("1 of 1 task completed") + end end end end diff --git a/spec/features/todos/todos_filtering_spec.rb b/spec/features/todos/todos_filtering_spec.rb new file mode 100644 index 00000000000..b9e66243d84 --- /dev/null +++ b/spec/features/todos/todos_filtering_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe 'Dashboard > User filters todos', feature: true, js: true do + include WaitForAjax + + let(:user_1) { create(:user, username: 'user_1', name: 'user_1') } + let(:user_2) { create(:user, username: 'user_2', name: 'user_2') } + + let(:project_1) { create(:empty_project, name: 'project_1') } + let(:project_2) { create(:empty_project, name: 'project_2') } + + let(:issue) { create(:issue, title: 'issue', project: project_1) } + + let!(:merge_request) { create(:merge_request, source_project: project_2, title: 'merge_request') } + + before do + create(:todo, user: user_1, author: user_2, project: project_1, target: issue, action: 1) + create(:todo, user: user_1, author: user_1, project: project_2, target: merge_request, action: 2) + + project_1.team << [user_1, :developer] + project_2.team << [user_1, :developer] + login_as(user_1) + visit dashboard_todos_path + end + + it 'filters by project' do + click_button 'Project' + within '.dropdown-menu-project' do + fill_in 'Search projects', with: project_1.name_with_namespace + click_link project_1.name_with_namespace + end + + wait_for_ajax + + expect(page).to have_content project_1.name_with_namespace + expect(page).not_to have_content project_2.name_with_namespace + end + + it 'filters by author' do + click_button 'Author' + within '.dropdown-menu-author' do + fill_in 'Search authors', with: user_1.name + click_link user_1.name + end + + wait_for_ajax + + expect(find('.todos-list')).to have_content user_1.name + expect(find('.todos-list')).not_to have_content user_2.name + end + + it 'filters by type' do + click_button 'Type' + within '.dropdown-menu-type' do + click_link 'Issue' + end + + wait_for_ajax + + expect(find('.todos-list')).to have_content issue.to_reference + expect(find('.todos-list')).not_to have_content merge_request.to_reference + end + + it 'filters by action' do + click_button 'Action' + within '.dropdown-menu-action' do + click_link 'Assigned' + end + + wait_for_ajax + + expect(find('.todos-list')).to have_content ' assigned you ' + expect(find('.todos-list')).not_to have_content ' mentioned ' + end +end diff --git a/spec/features/todos/todos_sorting_spec.rb b/spec/features/todos/todos_sorting_spec.rb new file mode 100644 index 00000000000..e74a51acede --- /dev/null +++ b/spec/features/todos/todos_sorting_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe "Dashboard > User sorts todos", feature: true do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + let(:label_1) { create(:label, title: 'label_1', project: project, priority: 1) } + let(:label_2) { create(:label, title: 'label_2', project: project, priority: 2) } + let(:label_3) { create(:label, title: 'label_3', project: project, priority: 3) } + + let(:issue_1) { create(:issue, title: 'issue_1', project: project) } + let(:issue_2) { create(:issue, title: 'issue_2', project: project) } + let(:issue_3) { create(:issue, title: 'issue_3', project: project) } + let(:issue_4) { create(:issue, title: 'issue_4', project: project) } + + let!(:merge_request_1) { create(:merge_request, source_project: project, title: "merge_request_1") } + + before do + create(:todo, user: user, project: project, target: issue_4, created_at: 5.hours.ago) + create(:todo, user: user, project: project, target: issue_2, created_at: 4.hours.ago) + create(:todo, user: user, project: project, target: issue_3, created_at: 3.hours.ago) + create(:todo, user: user, project: project, target: issue_1, created_at: 2.hours.ago) + create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago) + + merge_request_1.labels << label_1 + issue_3.labels << label_1 + issue_2.labels << label_3 + issue_1.labels << label_2 + + project.team << [user, :developer] + login_as(user) + visit dashboard_todos_path + end + + it "sorts with oldest created todos first" do + click_link "Last created" + + results_list = page.find('.todos-list') + expect(results_list.all('p')[0]).to have_content("merge_request_1") + expect(results_list.all('p')[1]).to have_content("issue_1") + expect(results_list.all('p')[2]).to have_content("issue_3") + expect(results_list.all('p')[3]).to have_content("issue_2") + expect(results_list.all('p')[4]).to have_content("issue_4") + end + + it "sorts with newest created todos first" do + click_link "Oldest created" + + results_list = page.find('.todos-list') + expect(results_list.all('p')[0]).to have_content("issue_4") + expect(results_list.all('p')[1]).to have_content("issue_2") + expect(results_list.all('p')[2]).to have_content("issue_3") + expect(results_list.all('p')[3]).to have_content("issue_1") + expect(results_list.all('p')[4]).to have_content("merge_request_1") + end + + it "sorts by priority" do + click_link "Priority" + + results_list = page.find('.todos-list') + expect(results_list.all('p')[0]).to have_content("issue_3") + expect(results_list.all('p')[1]).to have_content("merge_request_1") + expect(results_list.all('p')[2]).to have_content("issue_1") + expect(results_list.all('p')[3]).to have_content("issue_2") + expect(results_list.all('p')[4]).to have_content("issue_4") + end +end diff --git a/spec/features/todos/todos_spec.rb b/spec/features/todos/todos_spec.rb index 0bdb1628c74..bf93c1d1251 100644 --- a/spec/features/todos/todos_spec.rb +++ b/spec/features/todos/todos_spec.rb @@ -4,7 +4,7 @@ describe 'Dashboard Todos', feature: true do let(:user) { create(:user) } let(:author) { create(:user) } let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } - let(:issue) { create(:issue) } + let(:issue) { create(:issue, due_date: Date.today) } describe 'GET /dashboard/todos' do context 'User does not have todos' do @@ -24,10 +24,16 @@ describe 'Dashboard Todos', feature: true do visit dashboard_todos_path end - it 'todo is present' do + it 'has todo present' do expect(page).to have_selector('.todos-list .todo', count: 1) end + it 'shows due date as today' do + page.within first('.todo') do + expect(page).to have_content 'Due today' + end + end + describe 'deleting the todo' do before do first('.done-todo').click @@ -41,6 +47,27 @@ describe 'Dashboard Todos', feature: true do expect(page).to have_content("You're all done!") end end + + context 'todo is stale on the page' do + before do + todos = TodosFinder.new(user, state: :pending).execute + TodoService.new.mark_todos_as_done(todos, user) + end + + describe 'deleting the todo' do + before do + first('.done-todo').click + end + + it 'is removed from the list' do + expect(page).not_to have_selector('.todos-list .todo') + end + + it 'shows "All done" message' do + expect(page).to have_content("You're all done!") + end + end + end end context 'User has Todos with labels spanning multiple projects' do @@ -97,6 +124,20 @@ describe 'Dashboard Todos', feature: true do expect(page).to have_css("#todo_#{Todo.first.id}") end end + + describe 'mark all as done', js: true do + before do + visit dashboard_todos_path + click_link('Mark all as done') + end + + it 'shows "All done" message!' do + within('.todos-pending-count') { expect(page).to have_content '0' } + expect(page).to have_content 'To do 0' + expect(page).to have_content "You're all done!" + expect(page).not_to have_selector('.gl-pagination') + end + end end context 'User has a Todo in a project pending deletion' do diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 3cbc8253ad6..72354834c5a 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -12,7 +12,7 @@ describe 'Triggers' do context 'create a trigger' do before do - click_on 'Add Trigger' + click_on 'Add trigger' expect(@project.triggers.count).to eq(1) end diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index 9335f5bf120..ff6933dc8d9 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -1,13 +1,23 @@ require 'spec_helper' feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: true, js: true do + include WaitForAjax + before { allow_any_instance_of(U2fHelper).to receive(:inject_u2f_api?).and_return(true) } + def manage_two_factor_authentication + click_on 'Manage Two-Factor Authentication' + expect(page).to have_content("Setup New U2F Device") + wait_for_ajax + end + def register_u2f_device(u2f_device = nil) - u2f_device ||= FakeU2fDevice.new(page) + name = FFaker::Name.first_name + u2f_device ||= FakeU2fDevice.new(page, name) u2f_device.respond_to_u2f_registration click_on 'Setup New U2F Device' expect(page).to have_content('Your device was successfully set up') + fill_in "Pick a name", with: name click_on 'Register U2F Device' u2f_device end @@ -32,13 +42,14 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: end describe 'when 2FA via OTP is enabled' do - it 'allows registering a new device' do + it 'allows registering a new device with a name' do visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication expect(page.body).to match("You've already enabled two-factor authentication using mobile") - register_u2f_device + u2f_device = register_u2f_device + expect(page.body).to match(u2f_device.name) expect(page.body).to match('Your U2F device was registered') end @@ -46,23 +57,39 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: visit profile_account_path # First device - click_on 'Manage Two-Factor Authentication' - register_u2f_device + manage_two_factor_authentication + first_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') # Second device - click_on 'Manage Two-Factor Authentication' - register_u2f_device + second_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') - click_on 'Manage Two-Factor Authentication' - expect(page.body).to match('You have 2 U2F devices registered') + + expect(page.body).to match(first_device.name) + expect(page.body).to match(second_device.name) + expect(U2fRegistration.count).to eq(2) + end + + it 'allows deleting a device' do + visit profile_account_path + manage_two_factor_authentication + expect(page.body).to match("You've already enabled two-factor authentication using mobile") + + first_u2f_device = register_u2f_device + second_u2f_device = register_u2f_device + + click_on "Delete", match: :first + + expect(page.body).to match('Successfully deleted') + expect(page.body).not_to match(first_u2f_device.name) + expect(page.body).to match(second_u2f_device.name) end end it 'allows the same device to be registered for multiple users' do # First user visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication u2f_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') logout @@ -71,7 +98,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: user = login_as(:user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device(u2f_device) expect(page.body).to match('Your U2F device was registered') @@ -81,7 +108,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: context "when there are form errors" do it "doesn't register the device if there are errors" do visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication # Have the "u2f device" respond with bad data page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -96,7 +123,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "allows retrying registration" do visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication # Failed registration page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -122,13 +149,14 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: login_as(user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication @u2f_device = register_u2f_device logout end describe "when 2FA via OTP is disabled" do it "allows logging in with the U2F device" do + user.update_attribute(:otp_required_for_login, false) login_with(user) @u2f_device.respond_to_u2f_authentication @@ -154,6 +182,19 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: end end + it 'persists remember_me value via hidden field' do + login_with(user, remember: true) + + @u2f_device.respond_to_u2f_authentication + click_on "Login Via U2F Device" + expect(page.body).to match('We heard back from your U2F device') + + within 'div#js-authenticate-u2f' do + field = first('input#user_remember_me', visible: false) + expect(field.value).to eq '1' + end + end + describe "when a given U2F device has already been registered by another user" do describe "but not the current user" do it "does not allow logging in with that particular device" do @@ -161,7 +202,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: current_user = login_as(:user) current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device logout @@ -182,7 +223,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: current_user = login_as(:user) current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device(@u2f_device) logout @@ -200,7 +241,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe "when a given U2F device has not been registered" do it "does not allow logging in with that particular device" do - unregistered_device = FakeU2fDevice.new(page) + unregistered_device = FakeU2fDevice.new(page, FFaker::Name.first_name) login_as(user) unregistered_device.respond_to_u2f_authentication click_on "Login Via U2F Device" @@ -248,12 +289,13 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: user = login_as(:user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication expect(page).to have_content("Your U2F device needs to be set up.") register_u2f_device end it "deletes u2f registrations" do + visit profile_account_path expect { click_on "Disable" }.to change { U2fRegistration.count }.by(-1) end end diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb new file mode 100644 index 00000000000..33b52d1547e --- /dev/null +++ b/spec/features/unsubscribe_links_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe 'Unsubscribe links', feature: true do + include Warden::Test::Helpers + + let(:recipient) { create(:user) } + let(:author) { create(:user) } + let(:project) { create(:empty_project, :public) } + let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } } + let(:issue) { Issues::CreateService.new(project, author, params).execute } + + let(:mail) { ActionMailer::Base.deliveries.last } + let(:body) { Capybara::Node::Simple.new(mail.default_part_body.to_s) } + let(:header_link) { mail.header['List-Unsubscribe'].to_s[1..-2] } # Strip angle brackets + let(:body_link) { body.find_link('unsubscribe')['href'] } + + before do + perform_enqueued_jobs { issue } + end + + context 'when logged out' do + context 'when visiting the link from the body' do + it 'shows the unsubscribe confirmation page and redirects to root path when confirming' do + visit body_link + + expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last) + expect(page).to have_text(%(Unsubscribe from issue #{issue.title} (#{issue.to_reference}))) + expect(page).to have_text(%(Are you sure you want to unsubscribe from issue #{issue.title} (#{issue.to_reference})?)) + expect(issue.subscribed?(recipient)).to be_truthy + + click_link 'Unsubscribe' + + expect(issue.subscribed?(recipient)).to be_falsey + expect(current_path).to eq new_user_session_path + end + + it 'shows the unsubscribe confirmation page and redirects to root path when canceling' do + visit body_link + + expect(current_path).to eq unsubscribe_sent_notification_path(SentNotification.last) + expect(issue.subscribed?(recipient)).to be_truthy + + click_link 'Cancel' + + expect(issue.subscribed?(recipient)).to be_truthy + expect(current_path).to eq new_user_session_path + end + end + + it 'unsubscribes from the issue when visiting the link from the header' do + visit header_link + + expect(page).to have_text('unsubscribed') + expect(issue.subscribed?(recipient)).to be_falsey + end + end + + context 'when logged in' do + before { login_as(recipient) } + + it 'unsubscribes from the issue when visiting the link from the email body' do + visit body_link + + expect(page).to have_text('unsubscribed') + expect(issue.subscribed?(recipient)).to be_falsey + end + + it 'unsubscribes from the issue when visiting the link from the header' do + visit header_link + + expect(page).to have_text('unsubscribed') + expect(issue.subscribed?(recipient)).to be_falsey + end + end +end diff --git a/spec/features/users/snippets_spec.rb b/spec/features/users/snippets_spec.rb new file mode 100644 index 00000000000..ce7e809ec76 --- /dev/null +++ b/spec/features/users/snippets_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'Snippets tab on a user profile', feature: true, js: true do + include WaitForAjax + + context 'when the user has snippets' do + let(:user) { create(:user) } + let!(:snippets) { create_list(:snippet, 2, :public, author: user) } + before do + allow(Snippet).to receive(:default_per_page).and_return(1) + visit user_path(user) + page.within('.user-profile-nav') { click_link 'Snippets' } + wait_for_ajax + end + + it_behaves_like 'paginated snippets', remote: true + end +end diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb index b5a94fe0383..6498b7317b4 100644 --- a/spec/features/users_spec.rb +++ b/spec/features/users_spec.rb @@ -40,6 +40,17 @@ feature 'Users', feature: true do expect(number_of_errors_on_page(page)).to be(1), 'errors on page:\n #{errors_on_page page}' end + describe 'redirect alias routes' do + before { user } + + scenario '/u/user1 redirects to user page' do + visit '/u/user1' + + expect(current_path).to eq user_path(user) + expect(page).to have_text(user.name) + end + end + def errors_on_page(page) page.find('#error_explanation').find('ul').all('li').map{ |item| item.text }.join("\n") end diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb index a2b8f7b6931..d7880d5778f 100644 --- a/spec/features/variables_spec.rb +++ b/spec/features/variables_spec.rb @@ -13,13 +13,13 @@ describe 'Project variables', js: true do visit namespace_project_variables_path(project.namespace, project) end - it 'should show list of variables' do + it 'shows list of variables' do page.within('.variables-table') do expect(page).to have_content(variable.key) end end - it 'should add new variable' do + it 'adds new variable' do fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') click_button('Add new variable') @@ -29,7 +29,7 @@ describe 'Project variables', js: true do end end - it 'should delete variable' do + it 'deletes variable' do page.within('.variables-table') do find('.btn-variable-delete').click end @@ -37,11 +37,12 @@ describe 'Project variables', js: true do expect(page).not_to have_selector('variables-table') end - it 'should edit variable' do + it 'edits variable' do page.within('.variables-table') do find('.btn-variable-edit').click end + expect(page).to have_content('Update variable') fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') click_button('Save variable') diff --git a/spec/finders/access_requests_finder_spec.rb b/spec/finders/access_requests_finder_spec.rb new file mode 100644 index 00000000000..8cfea9659cb --- /dev/null +++ b/spec/finders/access_requests_finder_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe AccessRequestsFinder, services: true do + let(:user) { create(:user) } + let(:access_requester) { create(:user) } + let(:project) { create(:project, :public) } + let(:group) { create(:group, :public) } + + before do + project.request_access(access_requester) + group.request_access(access_requester) + end + + shared_examples 'a finder returning access requesters' do |method_name| + it 'returns access requesters' do + access_requesters = described_class.new(source).public_send(method_name, user) + + expect(access_requesters.size).to eq(1) + expect(access_requesters.first).to be_a "#{source.class}Member".constantize + expect(access_requesters.first.user).to eq(access_requester) + end + end + + shared_examples 'a finder returning no results' do |method_name| + it 'raises Gitlab::Access::AccessDeniedError' do + expect(described_class.new(source).public_send(method_name, user)).to be_empty + end + end + + shared_examples 'a finder raising Gitlab::Access::AccessDeniedError' do |method_name| + it 'raises Gitlab::Access::AccessDeniedError' do + expect { described_class.new(source).public_send(method_name, user) }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + describe '#execute' do + context 'when current user cannot see project access requests' do + it_behaves_like 'a finder returning no results', :execute do + let(:source) { project } + end + + it_behaves_like 'a finder returning no results', :execute do + let(:source) { group } + end + end + + context 'when current user can see access requests' do + before do + project.team << [user, :master] + group.add_owner(user) + end + + it_behaves_like 'a finder returning access requesters', :execute do + let(:source) { project } + end + + it_behaves_like 'a finder returning access requesters', :execute do + let(:source) { group } + end + end + end + + describe '#execute!' do + context 'when current user cannot see access requests' do + it_behaves_like 'a finder raising Gitlab::Access::AccessDeniedError', :execute! do + let(:source) { project } + end + + it_behaves_like 'a finder raising Gitlab::Access::AccessDeniedError', :execute! do + let(:source) { group } + end + end + + context 'when current user can see access requests' do + before do + project.team << [user, :master] + group.add_owner(user) + end + + it_behaves_like 'a finder returning access requesters', :execute! do + let(:source) { project } + end + + it_behaves_like 'a finder returning access requesters', :execute! do + let(:source) { group } + end + end + end +end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index ec8809e6926..40bccb8e50b 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -7,8 +7,8 @@ describe IssuesFinder do let(:project2) { create(:empty_project) } let(:milestone) { create(:milestone, project: project1) } let(:label) { create(:label, project: project2) } - let(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone) } - let(:issue2) { create(:issue, author: user, assignee: user, project: project2) } + let(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone, title: 'gitlab') } + let(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'gitlab') } let(:issue3) { create(:issue, author: user2, assignee: user2, project: project2) } let!(:label_link) { create(:label_link, label: label, target: issue2) } @@ -127,6 +127,22 @@ describe IssuesFinder do end end + context 'filtering by issue term' do + let(:params) { { search: 'git' } } + + it 'returns issues with title and description match for search term' do + expect(issues).to contain_exactly(issue1, issue2) + end + end + + context 'filtering by issue iid' do + let(:params) { { search: issue3.to_reference } } + + it 'returns issue with iid match' do + expect(issues).to contain_exactly(issue3) + end + end + context 'when the user is unauthorized' do let(:search_user) { nil } diff --git a/spec/finders/joined_groups_finder_spec.rb b/spec/finders/joined_groups_finder_spec.rb index f90a8e007c8..29a47e005a6 100644 --- a/spec/finders/joined_groups_finder_spec.rb +++ b/spec/finders/joined_groups_finder_spec.rb @@ -43,7 +43,7 @@ describe JoinedGroupsFinder do context 'if profile visitor is in one of the private group projects' do before do project = create(:project, :private, group: private_group, name: 'B', path: 'B') - project.team.add_user(profile_visitor, Gitlab::Access::DEVELOPER) + project.add_user(profile_visitor, Gitlab::Access::DEVELOPER) end it 'shows group' do diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index bc385fd0d69..535aabfc18d 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -18,13 +18,13 @@ describe MergeRequestsFinder do end describe "#execute" do - it 'should filter by scope' do + it 'filters by scope' do params = { scope: 'authored', state: 'opened' } merge_requests = MergeRequestsFinder.new(user, params).execute expect(merge_requests.size).to eq(2) end - it 'should filter by project' do + it 'filters by project' do params = { project_id: project1.id, scope: 'authored', state: 'opened' } merge_requests = MergeRequestsFinder.new(user, params).execute expect(merge_requests.size).to eq(1) diff --git a/spec/finders/move_to_project_finder_spec.rb b/spec/finders/move_to_project_finder_spec.rb new file mode 100644 index 00000000000..fdce4e714ff --- /dev/null +++ b/spec/finders/move_to_project_finder_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +describe MoveToProjectFinder do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:no_access_project) { create(:project) } + let(:guest_project) { create(:project) } + let(:reporter_project) { create(:project) } + let(:developer_project) { create(:project) } + let(:master_project) { create(:project) } + + subject { described_class.new(user) } + + describe '#execute' do + context 'filter' do + it 'does not return projects under Gitlab::Access::REPORTER' do + guest_project.team << [user, :guest] + + expect(subject.execute(project)).to be_empty + end + + it 'returns projects equal or above Gitlab::Access::REPORTER ordered by id in descending order' do + reporter_project.team << [user, :reporter] + developer_project.team << [user, :developer] + master_project.team << [user, :master] + + expect(subject.execute(project).to_a).to eq([master_project, developer_project, reporter_project]) + end + + it 'does not include the source project' do + project.team << [user, :reporter] + + expect(subject.execute(project).to_a).to be_empty + end + + it 'does not return archived projects' do + reporter_project.team << [user, :reporter] + reporter_project.update_attributes(archived: true) + other_reporter_project = create(:project) + other_reporter_project.team << [user, :reporter] + + expect(subject.execute(project).to_a).to eq([other_reporter_project]) + end + + it 'does not return projects for which issues are disabled' do + reporter_project.team << [user, :reporter] + reporter_project.update_attributes(issues_enabled: false) + other_reporter_project = create(:project) + other_reporter_project.team << [user, :reporter] + + expect(subject.execute(project).to_a).to eq([other_reporter_project]) + end + + it 'returns a page of projects ordered by id in descending order' do + stub_const 'MoveToProjectFinder::PAGE_SIZE', 2 + + reporter_project.team << [user, :reporter] + developer_project.team << [user, :developer] + master_project.team << [user, :master] + + expect(subject.execute(project).to_a).to eq([master_project, developer_project]) + end + + it 'returns projects after the given offset id' do + stub_const 'MoveToProjectFinder::PAGE_SIZE', 2 + + reporter_project.team << [user, :reporter] + developer_project.team << [user, :developer] + master_project.team << [user, :master] + + expect(subject.execute(project, search: nil, offset_id: master_project.id).to_a).to eq([developer_project, reporter_project]) + expect(subject.execute(project, search: nil, offset_id: developer_project.id).to_a).to eq([reporter_project]) + expect(subject.execute(project, search: nil, offset_id: reporter_project.id).to_a).to be_empty + end + end + + context 'search' do + it 'uses Project#search' do + expect(user).to receive_message_chain(:projects_where_can_admin_issues, :search) { Project.all } + + subject.execute(project, search: 'wadus') + end + + it 'returns projects matching a search query' do + foo_project = create(:project) + foo_project.team << [user, :master] + + wadus_project = create(:project, name: 'wadus') + wadus_project.team << [user, :master] + + expect(subject.execute(project).to_a).to eq([wadus_project, foo_project]) + expect(subject.execute(project, search: 'wadus').to_a).to eq([wadus_project]) + end + end + end +end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 8db897b1646..7c6860372cc 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -19,12 +19,12 @@ describe NotesFinder do note2 end - it 'should find all notes' do + it 'finds all notes' do notes = NotesFinder.new.execute(project, user, params) expect(notes.size).to eq(2) end - it 'should raise an exception for an invalid target_type' do + it 'raises an exception for an invalid target_type' do params.merge!(target_type: 'invalid') expect { NotesFinder.new.execute(project, user, params) }.to raise_error('invalid target_type') end diff --git a/spec/finders/pipelines_finder_spec.rb b/spec/finders/pipelines_finder_spec.rb new file mode 100644 index 00000000000..b0811d134fa --- /dev/null +++ b/spec/finders/pipelines_finder_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe PipelinesFinder do + let(:project) { create(:project) } + + let!(:tag_pipeline) { create(:ci_pipeline, project: project, ref: 'v1.0.0') } + let!(:branch_pipeline) { create(:ci_pipeline, project: project) } + + subject { described_class.new(project).execute(params) } + + describe "#execute" do + context 'when a scope is passed' do + context 'when scope is nil' do + let(:params) { { scope: nil } } + + it 'selects all pipelines' do + expect(subject.count).to be 2 + expect(subject).to include tag_pipeline + expect(subject).to include branch_pipeline + end + end + + context 'when selecting branches' do + let(:params) { { scope: 'branches' } } + + it 'excludes tags' do + expect(subject).not_to include tag_pipeline + expect(subject).to include branch_pipeline + end + end + + context 'when selecting tags' do + let(:params) { { scope: 'tags' } } + + it 'excludes branches' do + expect(subject).to include tag_pipeline + expect(subject).not_to include branch_pipeline + end + end + end + + # Scoping to running will speed up the test as it doesn't hit the FS + let(:params) { { scope: 'running' } } + + it 'orders in descending order on ID' do + feature_pipeline = create(:ci_pipeline, project: project, ref: 'feature') + + expected_ids = [feature_pipeline.id, branch_pipeline.id, tag_pipeline.id].sort.reverse + expect(subject.map(&:id)).to eq expected_ids + end + end +end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 0a1cc3b3df7..13bda5f7c5a 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -23,73 +23,36 @@ describe ProjectsFinder do let(:finder) { described_class.new } - describe 'without a group' do - describe 'without a user' do - subject { finder.execute } + describe 'without a user' do + subject { finder.execute } - it { is_expected.to eq([public_project]) } - end - - describe 'with a user' do - subject { finder.execute(user) } - - describe 'without private projects' do - it { is_expected.to eq([public_project, internal_project]) } - end - - describe 'with private projects' do - before do - private_project.team.add_user(user, Gitlab::Access::MASTER) - end - - it do - is_expected.to eq([public_project, internal_project, - private_project]) - end - end - end + it { is_expected.to eq([public_project]) } end - describe 'with a group' do - describe 'without a user' do - subject { finder.execute(nil, group: group) } + describe 'with a user' do + subject { finder.execute(user) } - it { is_expected.to eq([public_project]) } + describe 'without private projects' do + it { is_expected.to eq([public_project, internal_project]) } end - describe 'with a user' do - subject { finder.execute(user, group: group) } - - describe 'without shared projects' do - it { is_expected.to eq([public_project, internal_project]) } + describe 'with private projects' do + before do + private_project.add_user(user, Gitlab::Access::MASTER) end - describe 'with shared projects and group membership' do - before do - group.add_user(user, Gitlab::Access::DEVELOPER) - - shared_project.project_group_links. - create(group_access: Gitlab::Access::MASTER, group: group) - end - - it do - is_expected.to eq([shared_project, public_project, internal_project]) - end + it do + is_expected.to eq([public_project, internal_project, private_project]) end + end + end - describe 'with shared projects and project membership' do - before do - shared_project.team.add_user(user, Gitlab::Access::DEVELOPER) + describe 'with project_ids_relation' do + let(:project_ids_relation) { Project.where(id: internal_project.id) } - shared_project.project_group_links. - create(group_access: Gitlab::Access::MASTER, group: group) - end + subject { finder.execute(user, project_ids_relation) } - it do - is_expected.to eq([shared_project, public_project, internal_project]) - end - end - end + it { is_expected.to eq([internal_project]) } end end end diff --git a/spec/finders/tags_finder_spec.rb b/spec/finders/tags_finder_spec.rb new file mode 100644 index 00000000000..2ac810e478a --- /dev/null +++ b/spec/finders/tags_finder_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe TagsFinder do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:repository) { project.repository } + + describe '#execute' do + context 'sort only' do + it 'sorts by name' do + tags_finder = described_class.new(repository, {}) + + result = tags_finder.execute + + expect(result.first.name).to eq("v1.0.0") + end + + it 'sorts by recently_updated' do + tags_finder = described_class.new(repository, { sort: 'updated_desc' }) + + result = tags_finder.execute + recently_updated_tag = repository.tags.max do |a, b| + repository.commit(a.target).committed_date <=> repository.commit(b.target).committed_date + end + + expect(result.first.name).to eq(recently_updated_tag.name) + end + + it 'sorts by last_updated' do + tags_finder = described_class.new(repository, { sort: 'updated_asc' }) + + result = tags_finder.execute + + expect(result.first.name).to eq('v1.0.0') + end + end + + context 'filter only' do + it 'filters tags by name' do + tags_finder = described_class.new(repository, { search: '1.0.0' }) + + result = tags_finder.execute + + expect(result.first.name).to eq('v1.0.0') + expect(result.count).to eq(1) + end + + it 'does not find any tags with that name' do + tags_finder = described_class.new(repository, { search: 'hey' }) + + result = tags_finder.execute + + expect(result.count).to eq(0) + end + end + + context 'filter and sort' do + it 'filters tags by name and sorts by recently_updated' do + params = { sort: 'updated_desc', search: 'v1' } + tags_finder = described_class.new(repository, params) + + result = tags_finder.execute + + expect(result.first.name).to eq('v1.1.0') + expect(result.count).to eq(2) + end + + it 'filters tags by name and sorts by last_updated' do + params = { sort: 'updated_asc', search: 'v1' } + tags_finder = described_class.new(repository, params) + + result = tags_finder.execute + + expect(result.first.name).to eq('v1.0.0') + expect(result.count).to eq(2) + end + end + end +end diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb new file mode 100644 index 00000000000..f7e7e733cf7 --- /dev/null +++ b/spec/finders/todos_finder_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe TodosFinder do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:finder) { described_class } + + before { project.team << [user, :developer] } + + describe '#sort' do + context 'by date' do + let!(:todo1) { create(:todo, user: user, project: project) } + let!(:todo2) { create(:todo, user: user, project: project) } + let!(:todo3) { create(:todo, user: user, project: project) } + + it 'sorts with oldest created first' do + todos = finder.new(user, { sort: 'id_asc' }).execute + + expect(todos.first).to eq(todo1) + expect(todos.second).to eq(todo2) + expect(todos.third).to eq(todo3) + end + + it 'sorts with newest created first' do + todos = finder.new(user, { sort: 'id_desc' }).execute + + expect(todos.first).to eq(todo3) + expect(todos.second).to eq(todo2) + expect(todos.third).to eq(todo1) + end + end + + it "sorts by priority" do + label_1 = create(:label, title: 'label_1', project: project, priority: 1) + label_2 = create(:label, title: 'label_2', project: project, priority: 2) + label_3 = create(:label, title: 'label_3', project: project, priority: 3) + + issue_1 = create(:issue, title: 'issue_1', project: project) + issue_2 = create(:issue, title: 'issue_2', project: project) + issue_3 = create(:issue, title: 'issue_3', project: project) + issue_4 = create(:issue, title: 'issue_4', project: project) + merge_request_1 = create(:merge_request, source_project: project) + + merge_request_1.labels << label_1 + + # Covers the case where Todo has more than one label + issue_3.labels << label_1 + issue_3.labels << label_3 + + issue_2.labels << label_3 + issue_1.labels << label_2 + + todo_1 = create(:todo, user: user, project: project, target: issue_4) + todo_2 = create(:todo, user: user, project: project, target: issue_2) + todo_3 = create(:todo, user: user, project: project, target: issue_3, created_at: 2.hours.ago) + todo_4 = create(:todo, user: user, project: project, target: issue_1) + todo_5 = create(:todo, user: user, project: project, target: merge_request_1, created_at: 1.hour.ago) + + todos = finder.new(user, { sort: 'priority' }).execute + + expect(todos.first).to eq(todo_3) + expect(todos.second).to eq(todo_5) + expect(todos.third).to eq(todo_4) + expect(todos.fourth).to eq(todo_2) + expect(todos.fifth).to eq(todo_1) + end + end + end +end diff --git a/spec/finders/trending_projects_finder_spec.rb b/spec/finders/trending_projects_finder_spec.rb deleted file mode 100644 index a49cbfd5160..00000000000 --- a/spec/finders/trending_projects_finder_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'spec_helper' - -describe TrendingProjectsFinder do - let(:user) { build(:user) } - - describe '#execute' do - describe 'without an explicit start date' do - subject { described_class.new } - - it 'returns the trending projects' do - relation = double(:ar_relation) - - allow(subject).to receive(:projects_for) - .with(user) - .and_return(relation) - - allow(relation).to receive(:trending) - .with(an_instance_of(ActiveSupport::TimeWithZone)) - end - end - - describe 'with an explicit start date' do - let(:date) { 2.months.ago } - - subject { described_class.new } - - it 'returns the trending projects' do - relation = double(:ar_relation) - - allow(subject).to receive(:projects_for) - .with(user) - .and_return(relation) - - allow(relation).to receive(:trending) - .with(date) - end - end - end -end diff --git a/spec/fixtures/api/schemas/board.json b/spec/fixtures/api/schemas/board.json new file mode 100644 index 00000000000..03aca4a3cc0 --- /dev/null +++ b/spec/fixtures/api/schemas/board.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required" : [ + "id" + ], + "properties" : { + "id": { "type": "integer" }, + "name": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/boards.json b/spec/fixtures/api/schemas/boards.json new file mode 100644 index 00000000000..117564ef77a --- /dev/null +++ b/spec/fixtures/api/schemas/boards.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "board.json" } +} diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json new file mode 100644 index 00000000000..532ebb9640e --- /dev/null +++ b/spec/fixtures/api/schemas/issue.json @@ -0,0 +1,48 @@ +{ + "type": "object", + "required" : [ + "iid", + "title", + "confidential" + ], + "properties" : { + "iid": { "type": "integer" }, + "title": { "type": "string" }, + "confidential": { "type": "boolean" }, + "labels": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "text_color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + }, + "additionalProperties": false + } + }, + "assignee": { + "id": { "type": "integet" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/issues.json b/spec/fixtures/api/schemas/issues.json new file mode 100644 index 00000000000..70771b21c96 --- /dev/null +++ b/spec/fixtures/api/schemas/issues.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required" : [ + "issues", + "size" + ], + "properties" : { + "issues": { + "type": "array", + "items": { "$ref": "issue.json" } + }, + "size": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/list.json b/spec/fixtures/api/schemas/list.json new file mode 100644 index 00000000000..f070fa3b254 --- /dev/null +++ b/spec/fixtures/api/schemas/list.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "required" : [ + "id", + "list_type", + "title", + "position" + ], + "properties" : { + "id": { "type": "integer" }, + "list_type": { + "type": "string", + "enum": ["backlog", "label", "done"] + }, + "label": { + "type": ["object"], + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + } + }, + "title": { "type": "string" }, + "position": { "type": ["integer", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/lists.json b/spec/fixtures/api/schemas/lists.json new file mode 100644 index 00000000000..9f618aa9de5 --- /dev/null +++ b/spec/fixtures/api/schemas/lists.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "list.json" } +} diff --git a/spec/fixtures/config/redis_new_format_host.yml b/spec/fixtures/config/redis_new_format_host.yml new file mode 100644 index 00000000000..13772677a45 --- /dev/null +++ b/spec/fixtures/config/redis_new_format_host.yml @@ -0,0 +1,29 @@ +# redis://[:password@]host[:port][/db-number][?option=value] +# more details: http://www.iana.org/assignments/uri-schemes/prov/redis +development: + url: redis://:mynewpassword@localhost:6379/99 + sentinels: + - + host: localhost + port: 26380 # point to sentinel, not to redis port + - + host: slave2 + port: 26381 # point to sentinel, not to redis port +test: + url: redis://:mynewpassword@localhost:6379/99 + sentinels: + - + host: localhost + port: 26380 # point to sentinel, not to redis port + - + host: slave2 + port: 26381 # point to sentinel, not to redis port +production: + url: redis://:mynewpassword@localhost:6379/99 + sentinels: + - + host: slave1 + port: 26380 # point to sentinel, not to redis port + - + host: slave2 + port: 26381 # point to sentinel, not to redis port diff --git a/spec/fixtures/config/redis_new_format_socket.yml b/spec/fixtures/config/redis_new_format_socket.yml new file mode 100644 index 00000000000..4e76830c281 --- /dev/null +++ b/spec/fixtures/config/redis_new_format_socket.yml @@ -0,0 +1,6 @@ +development: + url: unix:/path/to/redis.sock +test: + url: unix:/path/to/redis.sock +production: + url: unix:/path/to/redis.sock diff --git a/spec/fixtures/config/redis_old_format_host.yml b/spec/fixtures/config/redis_old_format_host.yml new file mode 100644 index 00000000000..253d0a994f5 --- /dev/null +++ b/spec/fixtures/config/redis_old_format_host.yml @@ -0,0 +1,5 @@ +# redis://[:password@]host[:port][/db-number][?option=value] +# more details: http://www.iana.org/assignments/uri-schemes/prov/redis +development: redis://:mypassword@localhost:6379/99 +test: redis://:mypassword@localhost:6379/99 +production: redis://:mypassword@localhost:6379/99 diff --git a/spec/fixtures/config/redis_old_format_socket.yml b/spec/fixtures/config/redis_old_format_socket.yml new file mode 100644 index 00000000000..fd31ce8ea3d --- /dev/null +++ b/spec/fixtures/config/redis_old_format_socket.yml @@ -0,0 +1,3 @@ +development: unix:/path/to/old/redis.sock +test: unix:/path/to/old/redis.sock +production: unix:/path/to/old/redis.sock diff --git a/spec/fixtures/emails/commands_in_reply.eml b/spec/fixtures/emails/commands_in_reply.eml new file mode 100644 index 00000000000..06bf60ab734 --- /dev/null +++ b/spec/fixtures/emails/commands_in_reply.eml @@ -0,0 +1,43 @@ +Return-Path: <jake@adventuretime.ooo> +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog <jake@adventuretime.ooo> +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com> +In-Reply-To: <issue_1@localhost> +References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost> +Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux' +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +Cool! + +/close +/todo +/due tomorrow + + +On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta +<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote: +> +> +> +> eviltrout posted in 'Adventure Time Sux' on Discourse Meta: +> +> --- +> hey guys everyone knows adventure time sucks! +> +> --- +> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3 +> +> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences). +> diff --git a/spec/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml new file mode 100644 index 00000000000..aed64224b06 --- /dev/null +++ b/spec/fixtures/emails/commands_only_reply.eml @@ -0,0 +1,41 @@ +Return-Path: <jake@adventuretime.ooo> +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog <jake@adventuretime.ooo> +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com> +In-Reply-To: <issue_1@localhost> +References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost> +Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux' +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +/close +/todo +/due tomorrow + + +On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta +<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote: +> +> +> +> eviltrout posted in 'Adventure Time Sux' on Discourse Meta: +> +> --- +> hey guys everyone knows adventure time sucks! +> +> --- +> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3 +> +> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences). +> diff --git a/spec/fixtures/project_services/campfire/rooms.json b/spec/fixtures/project_services/campfire/rooms.json new file mode 100644 index 00000000000..71e9645c955 --- /dev/null +++ b/spec/fixtures/project_services/campfire/rooms.json @@ -0,0 +1,22 @@ +{ + "rooms": [ + { + "name": "test-room", + "locked": false, + "created_at": "2009/01/07 20:43:11 +0000", + "updated_at": "2009/03/18 14:31:39 +0000", + "topic": "The room topic\n", + "id": 123, + "membership_limit": 4 + }, + { + "name": "another room", + "locked": true, + "created_at": "2009/03/18 14:30:42 +0000", + "updated_at": "2013/01/27 14:14:27 +0000", + "topic": "Comment, ideas, GitHub notifications for eCommittee App", + "id": 456, + "membership_limit": 4 + } + ] +} diff --git a/spec/fixtures/project_services/campfire/rooms2.json b/spec/fixtures/project_services/campfire/rooms2.json new file mode 100644 index 00000000000..3d5f635d8b3 --- /dev/null +++ b/spec/fixtures/project_services/campfire/rooms2.json @@ -0,0 +1,22 @@ +{ + "rooms": [ + { + "name": "test-room-not-found", + "locked": false, + "created_at": "2009/01/07 20:43:11 +0000", + "updated_at": "2009/03/18 14:31:39 +0000", + "topic": "The room topic\n", + "id": 123, + "membership_limit": 4 + }, + { + "name": "another room", + "locked": true, + "created_at": "2009/03/18 14:30:42 +0000", + "updated_at": "2013/01/27 14:14:27 +0000", + "topic": "Comment, ideas, GitHub notifications for eCommittee App", + "id": 456, + "membership_limit": 4 + } + ] +} diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index bb28866f010..73f5470cf35 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -54,7 +54,7 @@ describe ApplicationHelper do describe 'project_icon' do let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') } - it 'should return an url for the avatar' do + it 'returns an url for the avatar' do project = create(:project, avatar: File.open(avatar_file_path)) avatar_url = "http://localhost/uploads/project/avatar/#{project.id}/banana_sample.gif" @@ -62,7 +62,7 @@ describe ApplicationHelper do to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end - it 'should give uploaded icon when present' do + it 'gives uploaded icon when present' do project = create(:project) allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) @@ -76,14 +76,14 @@ describe ApplicationHelper do describe 'avatar_icon' do let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') } - it 'should return an url for the avatar' do + it 'returns an url for the avatar' do user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") end - it 'should return an url for the avatar with relative url' do + it 'returns an url for the avatar with relative url' do stub_config_setting(relative_url_root: '/gitlab') # Must be stubbed after the stub above, and separately stub_config_setting(url: Settings.send(:build_gitlab_url)) @@ -94,14 +94,14 @@ describe ApplicationHelper do to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif") end - it 'should call gravatar_icon when no User exists with the given email' do + it 'calls gravatar_icon when no User exists with the given email' do expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2) helper.avatar_icon('foo@example.com', 20, 2) end describe 'using a User' do - it 'should return an URL for the avatar' do + it 'returns an URL for the avatar' do user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user).to_s). @@ -146,7 +146,7 @@ describe ApplicationHelper do to match('https://secure.gravatar.com') end - it 'should return custom gravatar path when gravatar_url is set' do + it 'returns custom gravatar path when gravatar_url is set' do stub_gravatar_setting(plain_url: 'http://example.local/?s=%{size}&hash=%{hash}') expect(gravatar_icon(user_email, 20)). @@ -218,12 +218,12 @@ describe ApplicationHelper do end it 'includes a default js-timeago class' do - expect(element.attr('class')).to eq 'time_ago js-timeago js-timeago-pending' + expect(element.attr('class')).to eq 'js-timeago js-timeago-pending' end it 'accepts a custom html_class' do expect(element(html_class: 'custom_class').attr('class')). - to eq 'custom_class js-timeago js-timeago-pending' + to eq 'js-timeago custom_class js-timeago-pending' end it 'accepts a custom tooltip placement' do @@ -244,6 +244,19 @@ describe ApplicationHelper do it 'converts to Time' do expect { helper.time_ago_with_tooltip(Date.today) }.not_to raise_error end + + it 'add class for the short format and includes inline script' do + timeago_element = element(short_format: 'short') + expect(timeago_element.attr('class')).to eq 'js-short-timeago js-timeago-pending' + script_element = timeago_element.next_element + expect(script_element.name).to eq 'script' + end + + it 'add class for the short format and does not include inline script' do + timeago_element = element(short_format: 'short', skip_js: true) + expect(timeago_element.attr('class')).to eq 'js-short-timeago' + expect(timeago_element.next_element).to eq nil + end end describe 'render_markup' do @@ -253,19 +266,19 @@ describe ApplicationHelper do allow(helper).to receive(:current_user).and_return(user) end - it 'should preserve encoding' do + it 'preserves encoding' do expect(content.encoding.name).to eq('UTF-8') expect(helper.render_markup('foo.rst', content).encoding.name).to eq('UTF-8') end - it "should delegate to #markdown when file name corresponds to Markdown" do + it "delegates to #markdown when file name corresponds to Markdown" do expect(helper).to receive(:gitlab_markdown?).with('foo.md').and_return(true) expect(helper).to receive(:markdown).and_return('NOEL') expect(helper.render_markup('foo.md', content)).to eq('NOEL') end - it "should delegate to #asciidoc when file name corresponds to AsciiDoc" do + it "delegates to #asciidoc when file name corresponds to AsciiDoc" do expect(helper).to receive(:asciidoc?).with('foo.adoc').and_return(true) expect(helper).to receive(:asciidoc).and_return('NOEL') diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index b2d6d59b1ee..a43a7238c70 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -17,19 +17,19 @@ describe BlobHelper do end describe '#highlight' do - it 'should return plaintext for unknown lexer context' do + it 'returns plaintext for unknown lexer context' do result = helper.highlight(blob_name, no_context_content) expect(result).to eq(%[<pre class="code highlight"><code><span id="LC1" class="line">:type "assem"))</span></code></pre>]) end - it 'should highlight single block' do + it 'highlights single block' do expected = %Q[<pre class="code highlight"><code><span id="LC1" class="line"><span class="p">(</span><span class="nb">make-pathname</span> <span class="ss">:defaults</span> <span class="nv">name</span></span> <span id="LC2" class="line"><span class="ss">:type</span> <span class="s">"assem"</span><span class="p">))</span></span></code></pre>] expect(helper.highlight(blob_name, blob_content)).to eq(expected) end - it 'should highlight multi-line comments' do + it 'highlights multi-line comments' do result = helper.highlight(blob_name, multiline_content) html = Nokogiri::HTML(result) lines = html.search('.s') @@ -49,7 +49,7 @@ describe BlobHelper do <span id="LC4" class="line"> ddd</span></code></pre>) end - it 'should highlight each line properly' do + it 'highlights each line properly' do result = helper.highlight(blob_name, blob_content) expect(result).to eq(expected) end @@ -62,25 +62,47 @@ describe BlobHelper do let(:expected_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'sanitized.svg') } let(:expected) { open(expected_svg_path).read } - it 'should retain essential elements' do + it 'retains essential elements' do blob = OpenStruct.new(data: data) expect(sanitize_svg(blob).data).to eq(expected) end end describe "#edit_blob_link" do - let(:project) { create(:project) } + let(:namespace) { create(:namespace, name: 'gitlab' )} + let(:project) { create(:project, namespace: namespace) } before do allow(self).to receive(:current_user).and_return(double) + allow(self).to receive(:can_collaborate_with_project?).and_return(true) end it 'verifies blob is text' do - expect(self).not_to receive(:blob_text_viewable?) + expect(helper).not_to receive(:blob_text_viewable?) button = edit_blob_link(project, 'refs/heads/master', 'README.md') expect(button).to start_with('<button') end + + it 'uses the passed blob instead retrieve from repository' do + blob = project.repository.blob_at('refs/heads/master', 'README.md') + + expect(project.repository).not_to receive(:blob_at) + + edit_blob_link(project, 'refs/heads/master', 'README.md', blob: blob) + end + + it 'returns a link with the proper route' do + link = edit_blob_link(project, 'master', 'README.md') + + expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md') + end + + it 'returns a link with the passed link_opts on the expected route' do + link = edit_blob_link(project, 'master', 'README.md', link_opts: { mr_id: 10 }) + + expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10') + end end end diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb index 157cc4665a2..c6e3c5c2368 100644 --- a/spec/helpers/broadcast_messages_helper_spec.rb +++ b/spec/helpers/broadcast_messages_helper_spec.rb @@ -7,7 +7,7 @@ describe BroadcastMessagesHelper do end it 'includes the current message' do - current = double(message: 'Current Message') + current = BroadcastMessage.new(message: 'Current Message') allow(helper).to receive(:broadcast_message_style).and_return(nil) @@ -15,7 +15,7 @@ describe BroadcastMessagesHelper do end it 'includes custom style' do - current = double(message: 'Current Message') + current = BroadcastMessage.new(message: 'Current Message') allow(helper).to receive(:broadcast_message_style).and_return('foo') diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index 4949280d641..9c7c79f57c6 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -15,76 +15,56 @@ describe DiffHelper do it 'returns a valid value when cookie is set' do helper.request.cookies[:diff_view] = 'parallel' - expect(helper.diff_view).to eq 'parallel' + expect(helper.diff_view).to eq :parallel end it 'returns a default value when cookie is invalid' do helper.request.cookies[:diff_view] = 'invalid' - expect(helper.diff_view).to eq 'inline' + expect(helper.diff_view).to eq :inline end it 'returns a default value when cookie is nil' do expect(helper.request.cookies).to be_empty - expect(helper.diff_view).to eq 'inline' + expect(helper.diff_view).to eq :inline end end - + describe 'diff_options' do - it 'should return no collapse false' do + it 'returns no collapse false' do expect(diff_options).to include(no_collapse: false) end - it 'should return no collapse true if expand_all_diffs' do + it 'returns no collapse true if expand_all_diffs' do allow(controller).to receive(:params) { { expand_all_diffs: true } } expect(diff_options).to include(no_collapse: true) end - it 'should return no collapse true if action name diff_for_path' do + it 'returns no collapse true if action name diff_for_path' do allow(controller).to receive(:action_name) { 'diff_for_path' } expect(diff_options).to include(no_collapse: true) end - it 'should return paths if action name diff_for_path and param old path' do + it 'returns paths if action name diff_for_path and param old path' do allow(controller).to receive(:params) { { old_path: 'lib/wadus.rb' } } allow(controller).to receive(:action_name) { 'diff_for_path' } expect(diff_options[:paths]).to include('lib/wadus.rb') end - it 'should return paths if action name diff_for_path and param new path' do + it 'returns paths if action name diff_for_path and param new path' do allow(controller).to receive(:params) { { new_path: 'lib/wadus.rb' } } allow(controller).to receive(:action_name) { 'diff_for_path' } expect(diff_options[:paths]).to include('lib/wadus.rb') end end - describe 'unfold_bottom_class' do - it 'should return empty string when bottom line shouldnt be unfolded' do - expect(unfold_bottom_class(false)).to eq('') - end - - it 'should return js class when bottom lines should be unfolded' do - expect(unfold_bottom_class(true)).to include('js-unfold-bottom') - end - end - - describe 'unfold_class' do - it 'returns empty on false' do - expect(unfold_class(false)).to eq('') - end - - it 'returns a class on true' do - expect(unfold_class(true)).to eq('unfold js-unfold') - end - end - describe '#diff_line_content' do - it 'should return non breaking space when line is empty' do + it 'returns non breaking space when line is empty' do expect(diff_line_content(nil)).to eq(' ') end - it 'should return the line itself' do + it 'returns the line itself' do expect(diff_line_content(diff_file.diff_lines.first.text)). to eq('@@ -6,12 +6,18 @@ module Popen') expect(diff_line_content(diff_file.diff_lines.first.type)).to eq('match') @@ -105,4 +85,56 @@ describe DiffHelper do expect(marked_new_line).to be_html_safe end end + + describe "#diff_match_line" do + let(:old_pos) { 40 } + let(:new_pos) { 50 } + let(:text) { 'some_text' } + + it "should generate foldable top match line for inline view with empty text by default" do + output = diff_match_line old_pos, new_pos + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css "td:nth-child(2):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(3):not(.parallel).line_content.match', text: '' + end + + it "should allow to define text and bottom option" do + output = diff_match_line old_pos, new_pos, text: text, bottom: true + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1).diff-line-num.unfold.js-unfold.js-unfold-bottom.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css "td:nth-child(2).diff-line-num.unfold.js-unfold.js-unfold-bottom.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(3):not(.parallel).line_content.match', text: text + end + + it "should generate match line for parallel view" do + output = diff_match_line old_pos, new_pos, text: text, view: :parallel + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(2).line_content.match.parallel', text: text + expect(output).to have_css "td:nth-child(3):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(4).line_content.match.parallel', text: text + end + + it "should allow to generate only left match line for parallel view" do + output = diff_match_line old_pos, nil, text: text, view: :parallel + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(2).line_content.match.parallel', text: text + expect(output).not_to have_css 'td:nth-child(3)' + end + + it "should allow to generate only right match line for parallel view" do + output = diff_match_line nil, new_pos, text: text, view: :parallel + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(2).line_content.match.parallel', text: text + expect(output).not_to have_css 'td:nth-child(3)' + end + end end diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb index 7a3e38d7e63..3223556e1d3 100644 --- a/spec/helpers/emails_helper_spec.rb +++ b/spec/helpers/emails_helper_spec.rb @@ -8,37 +8,37 @@ describe EmailsHelper do end context 'when time limit is less than 2 hours' do - it 'should display the time in hours using a singular unit' do + it 'displays the time in hours using a singular unit' do validate_time_string(1.hour, '1 hour') end end context 'when time limit is 2 or more hours' do - it 'should display the time in hours using a plural unit' do + it 'displays the time in hours using a plural unit' do validate_time_string(2.hours, '2 hours') end end context 'when time limit contains fractions of an hour' do - it 'should round down to the nearest hour' do + it 'rounds down to the nearest hour' do validate_time_string(96.minutes, '1 hour') end end context 'when time limit is 24 or more hours' do - it 'should display the time in days using a singular unit' do + it 'displays the time in days using a singular unit' do validate_time_string(24.hours, '1 day') end end context 'when time limit is 2 or more days' do - it 'should display the time in days using a plural unit' do + it 'displays the time in days using a plural unit' do validate_time_string(2.days, '2 days') end end context 'when time limit contains fractions of a day' do - it 'should round down to the nearest day' do + it 'rounds down to the nearest day' do validate_time_string(57.hours, '2 days') end end diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb index 6b5e3d93d48..022aba0c0d0 100644 --- a/spec/helpers/events_helper_spec.rb +++ b/spec/helpers/events_helper_spec.rb @@ -6,34 +6,34 @@ describe EventsHelper do allow(helper).to receive(:current_user).and_return(double) end - it 'should display one line of plain text without alteration' do + it 'displays one line of plain text without alteration' do input = 'A short, plain note' expect(helper.event_note(input)).to match(input) expect(helper.event_note(input)).not_to match(/\.\.\.\z/) end - it 'should display inline code' do + it 'displays inline code' do input = 'A note with `inline code`' expected = 'A note with <code>inline code</code>' expect(helper.event_note(input)).to match(expected) end - it 'should truncate a note with multiple paragraphs' do + it 'truncates a note with multiple paragraphs' do input = "Paragraph 1\n\nParagraph 2" expected = 'Paragraph 1...' expect(helper.event_note(input)).to match(expected) end - it 'should display the first line of a code block' do + it 'displays the first line of a code block' do input = "```\nCode block\nwith two lines\n```" expected = %r{<pre.+><code>Code block\.\.\.</code></pre>} expect(helper.event_note(input)).to match(expected) end - it 'should truncate a single long line of text' do + it 'truncates a single long line of text' do text = 'The quick brown fox jumped over the lazy dog twice' # 50 chars input = text * 4 expected = (text * 2).sub(/.{3}/, '...') @@ -41,7 +41,7 @@ describe EventsHelper do expect(helper.event_note(input)).to match(expected) end - it 'should preserve a link href when link text is truncated' do + it 'preserves a link href when link text is truncated' do text = 'The quick brown fox jumped over the lazy dog' # 44 chars input = "#{text}#{text}#{text} " # 133 chars link_url = 'http://example.com/foo/bar/baz' # 30 chars @@ -52,7 +52,7 @@ describe EventsHelper do expect(helper.event_note(input)).to match(expected_link_text) end - it 'should preserve code color scheme' do + it 'preserves code color scheme' do input = "```ruby\ndef test\n 'hello world'\nend\n```" expected = '<pre class="code highlight js-syntax-highlight ruby">' \ "<code><span class=\"k\">def</span> <span class=\"nf\">test</span>\n" \ diff --git a/spec/helpers/git_helper_spec.rb b/spec/helpers/git_helper_spec.rb new file mode 100644 index 00000000000..9b1ef1e05a2 --- /dev/null +++ b/spec/helpers/git_helper_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +describe GitHelper do + describe '#short_sha' do + let(:short_sha) { helper.short_sha('d4e043f6c20749a3ab3f4b8e23f2a8979f4b9100') } + + it { expect(short_sha).to eq('d4e043f6') } + end +end diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index ade5c3b02d9..5368e5fab06 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -26,17 +26,17 @@ describe GitlabMarkdownHelper do describe "referencing multiple objects" do let(:actual) { "#{merge_request.to_reference} -> #{commit.to_reference} -> #{issue.to_reference}" } - it "should link to the merge request" do + it "links to the merge request" do expected = namespace_project_merge_request_path(project.namespace, project, merge_request) expect(helper.markdown(actual)).to match(expected) end - it "should link to the commit" do + it "links to the commit" do expected = namespace_project_commit_path(project.namespace, project, commit) expect(helper.markdown(actual)).to match(expected) end - it "should link to the issue" do + it "links to the issue" do expected = namespace_project_issue_path(project.namespace, project, issue) expect(helper.markdown(actual)).to match(expected) end @@ -47,7 +47,7 @@ describe GitlabMarkdownHelper do let(:second_project) { create(:project, :public) } let(:second_issue) { create(:issue, project: second_project) } - it 'should link to the issue' do + it 'links to the issue' do expected = namespace_project_issue_path(second_project.namespace, second_project, second_issue) expect(markdown(actual, project: second_project)).to match(expected) end @@ -58,7 +58,7 @@ describe GitlabMarkdownHelper do let(:commit_path) { namespace_project_commit_path(project.namespace, project, commit) } let(:issues) { create_list(:issue, 2, project: project) } - it 'should handle references nested in links with all the text' do + it 'handles references nested in links with all the text' do actual = helper.link_to_gfm("This should finally fix #{issues[0].to_reference} and #{issues[1].to_reference} for real", commit_path) doc = Nokogiri::HTML.parse(actual) @@ -88,7 +88,7 @@ describe GitlabMarkdownHelper do expect(doc.css('a')[4].text).to eq ' for real' end - it 'should forward HTML options' do + it 'forwards HTML options' do actual = helper.link_to_gfm("Fixed in #{commit.id}", commit_path, class: 'foo') doc = Nokogiri::HTML.parse(actual) @@ -110,7 +110,7 @@ describe GitlabMarkdownHelper do expect(act).to eq %Q(<a href="/foo">#{issues[0].to_reference}</a>) end - it 'should replace commit message with emoji to link' do + it 'replaces commit message with emoji to link' do actual = link_to_gfm(':book:Book', '/foo') expect(actual). to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://localhost/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>) @@ -125,7 +125,7 @@ describe GitlabMarkdownHelper do helper.instance_variable_set(:@project_wiki, @wiki) end - it "should use Wiki pipeline for markdown files" do + it "uses Wiki pipeline for markdown files" do allow(@wiki).to receive(:format).and_return(:markdown) expect(helper).to receive(:markdown).with('wiki content', pipeline: :wiki, project_wiki: @wiki, page_slug: "nested/page") @@ -133,7 +133,7 @@ describe GitlabMarkdownHelper do helper.render_wiki_content(@wiki) end - it "should use Asciidoctor for asciidoc files" do + it "uses Asciidoctor for asciidoc files" do allow(@wiki).to receive(:format).and_return(:asciidoc) expect(helper).to receive(:asciidoc).with('wiki content') @@ -141,7 +141,7 @@ describe GitlabMarkdownHelper do helper.render_wiki_content(@wiki) end - it "should use the Gollum renderer for all other file types" do + it "uses the Gollum renderer for all other file types" do allow(@wiki).to receive(:format).and_return(:rdoc) formatted_content_stub = double('formatted_content') expect(formatted_content_stub).to receive(:html_safe) diff --git a/spec/helpers/graph_helper_spec.rb b/spec/helpers/graph_helper_spec.rb index 4acf38771b7..51c49f0e587 100644 --- a/spec/helpers/graph_helper_spec.rb +++ b/spec/helpers/graph_helper_spec.rb @@ -6,7 +6,7 @@ describe GraphHelper do let(:commit) { project.commit("master") } let(:graph) { Network::Graph.new(project, 'master', commit, '') } - it 'filter our refs used by GitLab' do + it 'filters our refs used by GitLab' do allow(commit).to receive(:ref_names).and_return(['refs/merge-requests/abc', 'master', 'refs/tmp/xyz']) self.instance_variable_set(:@graph, graph) refs = get_refs(project.repository, commit) diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 4ea90a80a92..233d00534e5 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -4,7 +4,7 @@ describe GroupsHelper do describe 'group_icon' do avatar_file_path = File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') - it 'should return an url for the avatar' do + it 'returns an url for the avatar' do group = create(:group) group.avatar = File.open(avatar_file_path) group.save! @@ -12,10 +12,73 @@ describe GroupsHelper do to match("/uploads/group/avatar/#{group.id}/banana_sample.gif") end - it 'should give default avatar_icon when no avatar is present' do + it 'gives default avatar_icon when no avatar is present' do group = create(:group) group.save! expect(group_icon(group.path)).to match('group_avatar.png') end end + + describe 'group_lfs_status' do + let(:group) { create(:group) } + let!(:project) { create(:empty_project, namespace_id: group.id) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + context 'only one project in group' do + before do + group.update_attribute(:lfs_enabled, true) + end + + it 'returns all projects as enabled' do + expect(group_lfs_status(group)).to include('Enabled for all projects') + end + + it 'returns all projects as disabled' do + project.update_attribute(:lfs_enabled, false) + + expect(group_lfs_status(group)).to include('Enabled for 0 out of 1 project') + end + end + + context 'more than one project in group' do + before do + create(:empty_project, namespace_id: group.id) + end + + context 'LFS enabled in group' do + before do + group.update_attribute(:lfs_enabled, true) + end + + it 'returns both projects as enabled' do + expect(group_lfs_status(group)).to include('Enabled for all projects') + end + + it 'returns only one as enabled' do + project.update_attribute(:lfs_enabled, false) + + expect(group_lfs_status(group)).to include('Enabled for 1 out of 2 projects') + end + end + + context 'LFS disabled in group' do + before do + group.update_attribute(:lfs_enabled, false) + end + + it 'returns both projects as disabled' do + expect(group_lfs_status(group)).to include('Disabled for all projects') + end + + it 'returns only one as disabled' do + project.update_attribute(:lfs_enabled, true) + + expect(group_lfs_status(group)).to include('Disabled for 1 out of 2 projects') + end + end + end + end end diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb index 3391234e9f5..187b891b927 100644 --- a/spec/helpers/import_helper_spec.rb +++ b/spec/helpers/import_helper_spec.rb @@ -1,6 +1,30 @@ require 'rails_helper' describe ImportHelper do + describe '#import_project_target' do + let(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + end + + context 'when current user can create namespaces' do + it 'returns project namespace' do + user.update_attribute(:can_create_group, true) + + expect(helper.import_project_target('asd', 'vim')).to eq 'asd/vim' + end + end + + context 'when current user can not create namespaces' do + it "takes the current user's namespace" do + user.update_attribute(:can_create_group, false) + + expect(helper.import_project_target('asd', 'vim')).to eq "#{user.namespace_path}/vim" + end + end + end + describe '#github_project_link' do context 'when provider does not specify a custom URL' do it 'uses default GitHub URL' do diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb new file mode 100644 index 00000000000..62cc10f579a --- /dev/null +++ b/spec/helpers/issuables_helper_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe IssuablesHelper do + let(:label) { build_stubbed(:label) } + let(:label2) { build_stubbed(:label) } + + describe '#issuable_labels_tooltip' do + it 'returns label text' do + expect(issuable_labels_tooltip([label])).to eq(label.title) + end + + it 'returns label text' do + expect(issuable_labels_tooltip([label, label2], limit: 1)).to eq("#{label.title}, and 1 more") + end + end + + describe '#issuables_state_counter_text' do + let(:user) { create(:user) } + + describe 'state text' do + before do + allow(helper).to receive(:issuables_count_for_state).and_return(42) + end + + it 'returns "Open" when state is :opened' do + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + end + + it 'returns "Closed" when state is :closed' do + expect(helper.issuables_state_counter_text(:issues, :closed)). + to eq('<span>Closed</span> <span class="badge">42</span>') + end + + it 'returns "Merged" when state is :merged' do + expect(helper.issuables_state_counter_text(:merge_requests, :merged)). + to eq('<span>Merged</span> <span class="badge">42</span>') + end + + it 'returns "All" when state is :all' do + expect(helper.issuables_state_counter_text(:merge_requests, :all)). + to eq('<span>All</span> <span class="badge">42</span>') + end + end + + describe 'counter caching based on issuable type and params', :caching do + let(:params) do + { + scope: 'created-by-me', + state: 'opened', + utf8: '✓', + author_id: '11', + assignee_id: '18', + label_name: ['bug', 'discussion', 'documentation'], + milestone_title: 'v4.0', + sort: 'due_date_asc', + namespace_id: 'gitlab-org', + project_id: 'gitlab-ce', + page: 2 + }.with_indifferent_access + end + + it 'returns the cached value when called for the same issuable type & with the same params' do + expect(helper).to receive(:params).twice.and_return(params) + expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42) + + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + + expect(helper).not_to receive(:issuables_count_for_state) + + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + end + + it 'does not take some keys into account in the cache key' do + expect(helper).to receive(:params).and_return({ + author_id: '11', + state: 'foo', + sort: 'foo', + utf8: 'foo', + page: 'foo' + }.with_indifferent_access) + expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42) + + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + + expect(helper).to receive(:params).and_return({ + author_id: '11', + state: 'bar', + sort: 'bar', + utf8: 'bar', + page: 'bar' + }.with_indifferent_access) + expect(helper).not_to receive(:issuables_count_for_state) + + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + end + + it 'does not take params order into account in the cache key' do + expect(helper).to receive(:params).and_return('author_id' => '11', 'state' => 'opened') + expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42) + + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + + expect(helper).to receive(:params).and_return('state' => 'opened', 'author_id' => '11') + expect(helper).not_to receive(:issuables_count_for_state) + + expect(helper.issuables_state_counter_text(:issues, :opened)). + to eq('<span>Open</span> <span class="badge">42</span>') + end + end + end +end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 9ee46dd2508..abe08d95ece 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -10,18 +10,19 @@ describe IssuesHelper do let(:ext_expected) { issues_url.gsub(':id', issue.iid.to_s).gsub(':project_id', ext_project.id.to_s) } let(:int_expected) { polymorphic_path([@project.namespace, project, issue]) } - it "should return internal path if used internal tracker" do + it "returns internal path if used internal tracker" do @project = project + expect(url_for_issue(issue.iid)).to match(int_expected) end - it "should return path to external tracker" do + it "returns path to external tracker" do @project = ext_project expect(url_for_issue(issue.iid)).to match(ext_expected) end - it "should return empty string if project nil" do + it "returns empty string if project nil" do @project = nil expect(url_for_issue(issue.iid)).to eq "" @@ -45,7 +46,7 @@ describe IssuesHelper do allow(Gitlab.config).to receive(:issues_tracker).and_return(nil) end - it "should return external path" do + it "returns external path" do expect(url_for_issue(issue.iid)).to match(ext_expected) end end @@ -61,6 +62,42 @@ describe IssuesHelper do it { is_expected.to eq("!1, !2, or !3") } end + describe '#award_user_list' do + it "returns a comma-separated list of the first X users" do + user = build_stubbed(:user, name: 'Joe') + awards = Array.new(3, build_stubbed(:award_emoji, user: user)) + + expect(award_user_list(awards, nil, limit: 3)) + .to eq('Joe, Joe, and Joe') + end + + it "displays the current user's name as 'You'" do + user = build_stubbed(:user, name: 'Joe') + award = build_stubbed(:award_emoji, user: user) + + expect(award_user_list([award], user)).to eq('You') + expect(award_user_list([award], nil)).to eq 'Joe' + end + + it "truncates lists" do + user = build_stubbed(:user, name: 'Jane') + awards = Array.new(5, build_stubbed(:award_emoji, user: user)) + + expect(award_user_list(awards, nil, limit: 3)) + .to eq('Jane, Jane, Jane, and 2 more.') + end + + it "displays the current user in front of other users" do + current_user = build_stubbed(:user) + my_award = build_stubbed(:award_emoji, user: current_user) + award = build_stubbed(:award_emoji, user: build_stubbed(:user, name: 'Jane')) + awards = Array.new(5, award).push(my_award) + + expect(award_user_list(awards, current_user, limit: 2)). + to eq("You, Jane, and 4 more.") + end + end + describe '#award_active_class' do let!(:upvote) { create(:award_emoji) } diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index f75fdb739f6..6703d88e357 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -9,57 +9,9 @@ describe MembersHelper do it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } end - describe '#default_show_roles' do - let(:user) { double } - let(:member) { build(:project_member) } - - before do - allow(helper).to receive(:current_user).and_return(user) - allow(helper).to receive(:can?).with(user, :update_project_member, member).and_return(false) - allow(helper).to receive(:can?).with(user, :destroy_project_member, member).and_return(false) - allow(helper).to receive(:can?).with(user, :admin_project_member, member.source).and_return(false) - end - - context 'when the current cannot update, destroy or admin the passed member' do - it 'returns false' do - expect(helper.default_show_roles(member)).to be_falsy - end - end - - context 'when the current can update the passed member' do - before do - allow(helper).to receive(:can?).with(user, :update_project_member, member).and_return(true) - end - - it 'returns true' do - expect(helper.default_show_roles(member)).to be_truthy - end - end - - context 'when the current can destroy the passed member' do - before do - allow(helper).to receive(:can?).with(user, :destroy_project_member, member).and_return(true) - end - - it 'returns true' do - expect(helper.default_show_roles(member)).to be_truthy - end - end - - context 'when the current can admin the passed member source' do - before do - allow(helper).to receive(:can?).with(user, :admin_project_member, member.source).and_return(true) - end - - it 'returns true' do - expect(helper.default_show_roles(member)).to be_truthy - end - end - end - describe '#remove_member_message' do let(:requester) { build(:user) } - let(:project) { create(:project) } + let(:project) { create(:empty_project, :public) } let(:project_member) { build(:project_member, project: project) } let(:project_member_invite) { build(:project_member, project: project).tap { |m| m.generate_invite_token! } } let(:project_member_request) { project.request_access(requester) } @@ -80,7 +32,7 @@ describe MembersHelper do describe '#remove_member_title' do let(:requester) { build(:user) } - let(:project) { create(:project) } + let(:project) { create(:empty_project, :public) } let(:project_member) { build(:project_member, project: project) } let(:project_member_request) { project.request_access(requester) } let(:group) { create(:group) } diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb new file mode 100644 index 00000000000..28c2268f8d0 --- /dev/null +++ b/spec/helpers/milestones_helper_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe MilestonesHelper do + describe '#milestone_counts' do + let(:project) { FactoryGirl.create(:project) } + let(:counts) { helper.milestone_counts(project.milestones) } + + context 'when there are milestones' do + let!(:milestone_1) { FactoryGirl.create(:active_milestone, project: project) } + let!(:milestone_2) { FactoryGirl.create(:active_milestone, project: project) } + let!(:milestone_3) { FactoryGirl.create(:closed_milestone, project: project) } + + it 'returns the correct counts' do + expect(counts).to eq(opened: 2, closed: 1, all: 3) + end + end + + context 'when there are only milestones of one type' do + let!(:milestone_1) { FactoryGirl.create(:active_milestone, project: project) } + let!(:milestone_2) { FactoryGirl.create(:active_milestone, project: project) } + + it 'returns the correct counts' do + expect(counts).to eq(opened: 2, closed: 0, all: 2) + end + end + + context 'when there are no milestones' do + it 'returns the correct counts' do + expect(counts).to eq(opened: 0, closed: 0, all: 0) + end + end + end +end diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb deleted file mode 100644 index e4d18d8bfc6..00000000000 --- a/spec/helpers/nav_helper_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -require 'spec_helper' - -# Specs in this file have access to a helper object that includes -# the NavHelper. For example: -# -# describe NavHelper do -# describe "string concat" do -# it "concats two strings with spaces" do -# expect(helper.concat_strings("this","that")).to eq("this that") -# end -# end -# end -describe NavHelper do - describe '#nav_menu_collapsed?' do - it 'returns true when the nav is collapsed in the cookie' do - helper.request.cookies[:collapsed_nav] = 'true' - expect(helper.nav_menu_collapsed?).to eq true - end - - it 'returns false when the nav is not collapsed in the cookie' do - helper.request.cookies[:collapsed_nav] = 'false' - expect(helper.nav_menu_collapsed?).to eq false - end - end -end diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index af371248ae9..9c577501f00 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -21,7 +21,7 @@ describe NotesHelper do end describe "#notes_max_access_for_users" do - it 'return human access levels' do + it 'returns human access levels' do expect(helper.note_max_access_for_user(owner_note)).to eq('Owner') expect(helper.note_max_access_for_user(master_note)).to eq('Master') expect(helper.note_max_access_for_user(reporter_note)).to eq('Reporter') @@ -38,6 +38,11 @@ describe NotesHelper do end describe '#preload_max_access_for_authors' do + before do + # This method reads cache from RequestStore, so make sure it's clean. + RequestStore.clear! + end + it 'loads multiple users' do expected_access = { owner.id => Gitlab::Access::OWNER, diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb index cf632f594c7..dc07657e101 100644 --- a/spec/helpers/page_layout_helper_spec.rb +++ b/spec/helpers/page_layout_helper_spec.rb @@ -97,5 +97,14 @@ describe PageLayoutHelper do expect(tags).to include %q(<meta property="twitter:data1" content="bar" />) end end + + it 'escapes content' do + allow(helper).to receive(:page_card_attributes) + .and_return(foo: %q{foo" http-equiv="refresh}.html_safe) + + tags = helper.page_card_meta_tags + + expect(tags).to include(%q{content="foo" http-equiv="refresh"}) + end end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 604204cca0a..8113742923b 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -11,7 +11,7 @@ describe ProjectsHelper do describe "can_change_visibility_level?" do let(:project) { create(:project) } - let(:user) { create(:user) } + let(:user) { create(:project_member, :reporter, user: create(:user), project: project).user } let(:fork_project) { Projects::ForkService.new(project, user).execute } it "returns false if there are no appropriate permissions" do @@ -72,7 +72,7 @@ describe ProjectsHelper do it 'returns an HTML link to the user' do link = helper.link_to_member(project, user) - expect(link).to match(%r{/u/#{user.username}}) + expect(link).to match(%r{/#{user.username}}) end end end @@ -136,4 +136,86 @@ describe ProjectsHelper do expect(sanitize_repo_path(project, import_error)).to eq('Could not clone [REPOS PATH]/namespace/test.git') end end + + describe '#last_push_event' do + let(:user) { double(:user, fork_of: nil) } + let(:project) { double(:project, id: 1) } + + before do + allow(helper).to receive(:current_user).and_return(user) + helper.instance_variable_set(:@project, project) + end + + context 'when there is no current_user' do + let(:user) { nil } + + it 'returns nil' do + expect(helper.last_push_event).to eq(nil) + end + end + + it 'returns recent push on the current project' do + event = double(:event) + expect(user).to receive(:recent_push).with([project.id]).and_return(event) + + expect(helper.last_push_event).to eq(event) + end + + context 'when current user has a fork of the current project' do + let(:fork) { double(:fork, id: 2) } + + it 'returns recent push considering fork events' do + expect(user).to receive(:fork_of).with(project).and_return(fork) + + event_on_fork = double(:event) + expect(user).to receive(:recent_push).with([project.id, fork.id]).and_return(event_on_fork) + + expect(helper.last_push_event).to eq(event_on_fork) + end + end + end + + describe "#project_feature_access_select" do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + context "when project is internal or public" do + it "shows all options" do + helper.instance_variable_set(:@project, project) + result = helper.project_feature_access_select(:issues_access_level) + expect(result).to include("Disabled") + expect(result).to include("Only team members") + expect(result).to include("Everyone with access") + end + end + + context "when project is private" do + before { project.update_attributes(visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + + it "shows only allowed options" do + helper.instance_variable_set(:@project, project) + result = helper.project_feature_access_select(:issues_access_level) + expect(result).to include("Disabled") + expect(result).to include("Only team members") + expect(result).not_to include("Everyone with access") + end + end + + context "when project moves from public to private" do + before do + project.project_feature.update_attributes(issues_access_level: ProjectFeature::ENABLED) + project.update_attributes(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it "shows the highest allowed level selected" do + helper.instance_variable_set(:@project, project) + result = helper.project_feature_access_select(:issues_access_level) + + expect(result).to include("Disabled") + expect(result).to include("Only team members") + expect(result).not_to include("Everyone with access") + expect(result).to have_selector('option[selected]', text: "Only team members") + end + end + end end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 601b6915e27..64aa41020c9 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -6,6 +6,38 @@ describe SearchHelper do str end + describe 'parsing result' do + let(:project) { create(:project) } + let(:repository) { project.repository } + let(:results) { repository.search_files('feature', 'master') } + let(:search_result) { results.first } + + subject { helper.parse_search_result(search_result) } + + it "returns a valid OpenStruct object" do + is_expected.to be_an OpenStruct + expect(subject.filename).to eq('CHANGELOG') + expect(subject.basename).to eq('CHANGELOG') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(188) + expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") + end + + context "when filename has extension" do + let(:search_result) { "master:CONTRIBUTE.md:5:- [Contribute to GitLab](#contribute-to-gitlab)\n" } + + it { expect(subject.filename).to eq('CONTRIBUTE.md') } + it { expect(subject.basename).to eq('CONTRIBUTE') } + end + + context "when file under directory" do + let(:search_result) { "master:a/b/c.md:5:a b c\n" } + + it { expect(subject.filename).to eq('a/b/c.md') } + it { expect(subject.basename).to eq('a/b/c') } + end + end + describe 'search_autocomplete_source' do context "with no current user" do before do @@ -32,6 +64,10 @@ describe SearchHelper do expect(search_autocomplete_opts("adm").size).to eq(1) end + it "does not allow regular expression in search term" do + expect(search_autocomplete_opts("(webhooks|api)").size).to eq(0) + end + it "includes the user's groups" do create(:group).add_owner(user) expect(search_autocomplete_opts("gro").size).to eq(1) @@ -42,7 +78,7 @@ describe SearchHelper do expect(search_autocomplete_opts(project.name).size).to eq(1) end - it "should not include the public group" do + it "does not include the public group" do group = create(:group) expect(search_autocomplete_opts(group.name).size).to eq(0) end diff --git a/spec/helpers/sidekiq_helper_spec.rb b/spec/helpers/sidekiq_helper_spec.rb new file mode 100644 index 00000000000..d60839b78ec --- /dev/null +++ b/spec/helpers/sidekiq_helper_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe SidekiqHelper do + describe 'parse_sidekiq_ps' do + it 'parses line with time' do + line = '55137 10,0 2,1 S+ 2:30pm sidekiq 4.1.4 gitlab [0 of 25 busy] ' + parts = helper.parse_sidekiq_ps(line) + + expect(parts).to eq(['55137', '10,0', '2,1', 'S+', '2:30pm', 'sidekiq 4.1.4 gitlab [0 of 25 busy]']) + end + + it 'parses line with date' do + line = '55137 10,0 2,1 S+ Aug 4 sidekiq 4.1.4 gitlab [0 of 25 busy] ' + parts = helper.parse_sidekiq_ps(line) + + expect(parts).to eq(['55137', '10,0', '2,1', 'S+', 'Aug 4', 'sidekiq 4.1.4 gitlab [0 of 25 busy]']) + end + + it 'parses line with two digit date' do + line = '55137 10,0 2,1 S+ Aug 04 sidekiq 4.1.4 gitlab [0 of 25 busy] ' + parts = helper.parse_sidekiq_ps(line) + + expect(parts).to eq(['55137', '10,0', '2,1', 'S+', 'Aug 04', 'sidekiq 4.1.4 gitlab [0 of 25 busy]']) + end + + it 'parses line with dot as float separator' do + line = '55137 10.0 2.1 S+ 2:30pm sidekiq 4.1.4 gitlab [0 of 25 busy] ' + parts = helper.parse_sidekiq_ps(line) + + expect(parts).to eq(['55137', '10.0', '2.1', 'S+', '2:30pm', 'sidekiq 4.1.4 gitlab [0 of 25 busy]']) + end + + it 'does fail gracefully on line not matching the format' do + line = '55137 10.0 2.1 S+ 2:30pm something' + parts = helper.parse_sidekiq_ps(line) + + expect(parts).to eq(['?', '?', '?', '?', '?', '?']) + end + end +end diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index 10121759132..37ac6a2699d 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -17,35 +17,35 @@ describe SubmoduleHelper do allow(Gitlab.config.gitlab).to receive(:protocol).and_return('http') # set this just to be sure end - it 'should detect ssh on standard port' do + it 'detects ssh on standard port' do allow(Gitlab.config.gitlab_shell).to receive(:ssh_port).and_return(22) # set this just to be sure allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix)) stub_url([ config.user, '@', config.host, ':gitlab-org/gitlab-ce.git' ].join('')) expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) end - it 'should detect ssh on non-standard port' do + it 'detects ssh on non-standard port' do allow(Gitlab.config.gitlab_shell).to receive(:ssh_port).and_return(2222) allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix)) stub_url([ 'ssh://', config.user, '@', config.host, ':2222/gitlab-org/gitlab-ce.git' ].join('')) expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) end - it 'should detect http on standard port' do + it 'detects http on standard port' do allow(Gitlab.config.gitlab).to receive(:port).and_return(80) allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) stub_url([ 'http://', config.host, '/gitlab-org/gitlab-ce.git' ].join('')) expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) end - it 'should detect http on non-standard port' do + it 'detects http on non-standard port' do allow(Gitlab.config.gitlab).to receive(:port).and_return(3000) allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) stub_url([ 'http://', config.host, ':3000/gitlab-org/gitlab-ce.git' ].join('')) expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) end - it 'should work with relative_url_root' do + it 'works with relative_url_root' do allow(Gitlab.config.gitlab).to receive(:port).and_return(80) # set this just to be sure allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root') allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) @@ -55,22 +55,22 @@ describe SubmoduleHelper do end context 'submodule on github.com' do - it 'should detect ssh' do + it 'detects ssh' do stub_url('git@github.com:gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([ 'https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash' ]) end - it 'should detect http' do + it 'detects http' do stub_url('http://github.com/gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([ 'https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash' ]) end - it 'should detect https' do + it 'detects https' do stub_url('https://github.com/gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([ 'https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash' ]) end - it 'should return original with non-standard url' do + it 'returns original with non-standard url' do stub_url('http://github.com/gitlab-org/gitlab-ce') expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) @@ -80,22 +80,22 @@ describe SubmoduleHelper do end context 'submodule on gitlab.com' do - it 'should detect ssh' do + it 'detects ssh' do stub_url('git@gitlab.com:gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([ 'https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash' ]) end - it 'should detect http' do + it 'detects http' do stub_url('http://gitlab.com/gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([ 'https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash' ]) end - it 'should detect https' do + it 'detects https' do stub_url('https://gitlab.com/gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([ 'https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash' ]) end - it 'should return original with non-standard url' do + it 'returns original with non-standard url' do stub_url('http://gitlab.com/gitlab-org/gitlab-ce') expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) @@ -105,7 +105,7 @@ describe SubmoduleHelper do end context 'submodule on unsupported' do - it 'should return original' do + it 'returns original' do stub_url('http://mygitserver.com/gitlab-org/gitlab-ce') expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) diff --git a/spec/helpers/time_helper_spec.rb b/spec/helpers/time_helper_spec.rb index bf3ed5c094c..21f35585367 100644 --- a/spec/helpers/time_helper_spec.rb +++ b/spec/helpers/time_helper_spec.rb @@ -19,16 +19,16 @@ describe TimeHelper do describe "#duration_in_numbers" do it "returns minutes and seconds" do - duration_in_numbers = { - [100, 0] => "01:40", - [121, 0] => "02:01", - [3721, 0] => "01:02:01", - [0, 0] => "00:00", - [nil, Time.now.to_i - 42] => "00:42" + durations_and_expectations = { + 100 => "01:40", + 121 => "02:01", + 3721 => "01:02:01", + 0 => "00:00", + 42 => "00:42" } - duration_in_numbers.each do |interval, expectation| - expect(duration_in_numbers(*interval)).to eq(expectation) + durations_and_expectations.each do |duration, expectation| + expect(duration_in_numbers(duration)).to eq(expectation) end end end diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb index c70dd8076e0..8d6537ba4b5 100644 --- a/spec/helpers/tree_helper_spec.rb +++ b/spec/helpers/tree_helper_spec.rb @@ -12,7 +12,7 @@ describe TreeHelper do context "on a directory containing more than one file/directory" do let(:tree_item) { double(name: "files", path: "files") } - it "should return the directory name" do + it "returns the directory name" do expect(flatten_tree(tree_item)).to match('files') end end @@ -20,7 +20,7 @@ describe TreeHelper do context "on a directory containing only one directory" do let(:tree_item) { double(name: "foo", path: "foo") } - it "should return the flattened path" do + it "returns the flattened path" do expect(flatten_tree(tree_item)).to match('foo/bar') end end diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb new file mode 100644 index 00000000000..837b0de9a4c --- /dev/null +++ b/spec/initializers/secret_token_spec.rb @@ -0,0 +1,200 @@ +require 'spec_helper' +require_relative '../../config/initializers/secret_token' + +describe 'create_tokens', lib: true do + 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) + allow(Rails).to receive_message_chain(:root, :join) { |string| string } + allow(self).to receive(:warn) + allow(self).to receive(:exit) + end + + 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) + 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) + end + + it 'generates different secrets for secret_key_base, otp_key_base, and db_key_base' do + create_tokens + + keys = secrets.values_at(:secret_key_base, :otp_key_base, :db_key_base) + + expect(keys.uniq).to eq(keys) + expect(keys.map(&:length)).to all(eq(128)) + end + + it 'warns about the secrets to add to secrets.yml' do + expect(self).to receive(:warn_missing_secret).with('secret_key_base') + expect(self).to receive(:warn_missing_secret).with('otp_key_base') + expect(self).to receive(:warn_missing_secret).with('db_key_base') + + create_tokens + end + + it 'writes the secrets to secrets.yml' do + expect(File).to receive(:write).with('config/secrets.yml', any_args) do |filename, contents, options| + new_secrets = YAML.load(contents)[Rails.env] + + expect(new_secrets['secret_key_base']).to eq(secrets.secret_key_base) + expect(new_secrets['otp_key_base']).to eq(secrets.otp_key_base) + expect(new_secrets['db_key_base']).to eq(secrets.db_key_base) + end + + create_tokens + end + + it 'does not write a .secret file' do + expect(File).not_to receive(:write).with('.secret') + + create_tokens + end + end + + context 'when the other secrets all exist' do + before do + secrets.db_key_base = 'db_key_base' + + allow(File).to receive(:exist?).with('.secret').and_return(true) + allow(File).to receive(:read).with('.secret').and_return('file_key') + end + + 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') + secrets.secret_key_base = 'secret_key_base' + secrets.otp_key_base = 'otp_key_base' + end + + it 'does not issue a warning' do + expect(self).not_to receive(:warn) + + create_tokens + end + + it 'uses the environment variable' do + create_tokens + + expect(secrets.secret_key_base).to eq('env_key') + end + + it 'does not update secrets.yml' do + expect(File).not_to receive(:write) + + create_tokens + end + end + + context 'when secret_key_base and otp_key_base exist' do + before do + secrets.secret_key_base = 'secret_key_base' + secrets.otp_key_base = 'otp_key_base' + end + + it 'does not write any files' do + expect(File).not_to receive(:write) + + create_tokens + end + + it 'sets the the keys to the values from the environment and secrets.yml' do + create_tokens + + expect(secrets.secret_key_base).to eq('secret_key_base') + expect(secrets.otp_key_base).to eq('otp_key_base') + expect(secrets.db_key_base).to eq('db_key_base') + end + + it 'deletes the .secret file' do + expect(File).to receive(:delete).with('.secret') + + create_tokens + end + end + + context 'when secret_key_base and otp_key_base do not exist' do + before do + allow(File).to receive(:exist?).with('config/secrets.yml').and_return(true) + allow(YAML).to receive(:load_file).with('config/secrets.yml').and_return('test' => secrets.to_h.stringify_keys) + allow(self).to receive(:warn_missing_secret) + end + + it 'uses the file secret' do + expect(File).to receive(:write) do |filename, contents, options| + new_secrets = YAML.load(contents)[Rails.env] + + expect(new_secrets['secret_key_base']).to eq('file_key') + expect(new_secrets['otp_key_base']).to eq('file_key') + expect(new_secrets['db_key_base']).to eq('db_key_base') + end + + create_tokens + + expect(secrets.otp_key_base).to eq('file_key') + end + + it 'keeps the other secrets as they were' do + create_tokens + + expect(secrets.db_key_base).to eq('db_key_base') + end + + it 'warns about the missing secrets' do + expect(self).to receive(:warn_missing_secret).with('secret_key_base') + expect(self).to receive(:warn_missing_secret).with('otp_key_base') + + create_tokens + end + + it 'deletes the .secret file' do + expect(File).to receive(:delete).with('.secret') + + create_tokens + end + end + end + + context 'when db_key_base is blank but exists in secrets.yml' do + before do + secrets.otp_key_base = 'otp_key_base' + secrets.secret_key_base = 'secret_key_base' + yaml_secrets = secrets.to_h.stringify_keys.merge('db_key_base' => '<%= an_erb_expression %>') + + allow(File).to receive(:exist?).with('.secret').and_return(false) + allow(File).to receive(:exist?).with('config/secrets.yml').and_return(true) + allow(YAML).to receive(:load_file).with('config/secrets.yml').and_return('test' => yaml_secrets) + allow(self).to receive(:warn_missing_secret) + end + + it 'warns about updating db_key_base' do + expect(self).to receive(:warn_missing_secret).with('db_key_base') + + create_tokens + end + + it 'warns about the blank value existing in secrets.yml and exits' do + expect(self).to receive(:warn) do |warning| + expect(warning).to include('db_key_base') + expect(warning).to include('<%= an_erb_expression %>') + end + + create_tokens + end + + it 'does not update secrets.yml' do + expect(self).to receive(:exit).with(1).and_call_original + expect(File).not_to receive(:write) + + expect { create_tokens }.to raise_error(SystemExit) + end + end + end +end diff --git a/spec/javascripts/abuse_reports_spec.js.es6 b/spec/javascripts/abuse_reports_spec.js.es6 new file mode 100644 index 00000000000..6bcfdf191c2 --- /dev/null +++ b/spec/javascripts/abuse_reports_spec.js.es6 @@ -0,0 +1,41 @@ +/*= require abuse_reports */ + +/*= require jquery */ + +((global) => { + const FIXTURE = 'abuse_reports.html'; + const MAX_MESSAGE_LENGTH = 500; + + function assertMaxLength($message) { + expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH); + } + + describe('Abuse Reports', function() { + fixture.preload(FIXTURE); + + beforeEach(function() { + fixture.load(FIXTURE); + new global.AbuseReports(); + }); + + it('should truncate long messages', function() { + const $longMessage = $('#long'); + expect($longMessage.data('original-message')).toEqual(jasmine.anything()); + assertMaxLength($longMessage); + }); + + it('should not truncate short messages', function() { + const $shortMessage = $('#short'); + expect($shortMessage.data('original-message')).not.toEqual(jasmine.anything()); + }); + + it('should allow clicking a truncated message to expand and collapse the full message', function() { + const $longMessage = $('#long'); + $longMessage.click(); + expect($longMessage.data('original-message').length).toEqual($longMessage.text().length); + $longMessage.click(); + assertMaxLength($longMessage); + }); + }); + +})(window.gl); diff --git a/spec/javascripts/activities_spec.js.es6 b/spec/javascripts/activities_spec.js.es6 new file mode 100644 index 00000000000..743b15460c6 --- /dev/null +++ b/spec/javascripts/activities_spec.js.es6 @@ -0,0 +1,61 @@ +/*= require jquery.cookie.js */ +/*= require jquery.endless-scroll.js */ +/*= require pager */ +/*= require activities */ + +(() => { + window.gon || (window.gon = {}); + const fixtureTemplate = 'event_filter.html'; + const filters = [ + { + id: 'all', + }, { + id: 'push', + name: 'push events', + }, { + id: 'merged', + name: 'merge events', + }, { + id: 'comments', + },{ + id: 'team', + }]; + + function getEventName(index) { + let filter = filters[index]; + return filter.hasOwnProperty('name') ? filter.name : filter.id; + } + + function getSelector(index) { + let filter = filters[index]; + return `#${filter.id}_event_filter` + } + + describe('Activities', () => { + beforeEach(() => { + fixture.load(fixtureTemplate); + new Activities(); + }); + + for(let i = 0; i < filters.length; i++) { + ((i) => { + describe(`when selecting ${getEventName(i)}`, () => { + beforeEach(() => { + $(getSelector(i)).click(); + }); + + for(let x = 0; x < filters.length; x++) { + ((x) => { + let shouldHighlight = i === x; + let testName = shouldHighlight ? 'should highlight' : 'should not highlight'; + + it(`${testName} ${getEventName(x)}`, () => { + expect($(getSelector(x)).parent().hasClass('active')).toEqual(shouldHighlight); + }); + })(x); + } + }); + })(i); + } + }); +})(); diff --git a/spec/javascripts/application_spec.js b/spec/javascripts/application_spec.js index b48026c3b77..56b98856614 100644 --- a/spec/javascripts/application_spec.js +++ b/spec/javascripts/application_spec.js @@ -13,17 +13,21 @@ gl.utils.preventDisabledButtons(); isClicked = false; $button = $('#test-button'); + expect($button).toExist(); $button.click(function() { return isClicked = true; }); $button.trigger('click'); return expect(isClicked).toBe(false); }); - return it('should be on the same page if a disabled link clicked', function() { - var locationBeforeLinkClick; + + it('should be on the same page if a disabled link clicked', function() { + var locationBeforeLinkClick, $link; locationBeforeLinkClick = window.location.href; gl.utils.preventDisabledButtons(); - $('#test-link').click(); + $link = $('#test-link'); + expect($link).toExist(); + $link.click(); return expect(window.location.href).toBe(locationBeforeLinkClick); }); }); diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 3ddc163033e..019ce3b0702 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -1,17 +1,11 @@ /*= require awards_handler */ - - /*= require jquery */ - - /*= require jquery.cookie */ - - /*= require ./fixtures/emoji_menu */ (function() { - var awardsHandler, lazyAssert; + var awardsHandler, lazyAssert, urlRoot; awardsHandler = null; @@ -27,11 +21,13 @@ }; gon.award_menu_url = '/emojis'; + urlRoot = gon.relative_url_root; lazyAssert = function(done, assertFn) { return setTimeout(function() { assertFn(); return done(); + // Maybe jasmine.clock here? }, 333); }; @@ -45,9 +41,14 @@ return cb(); }; })(this)); - return spyOn(jQuery, 'get').and.callFake(function(req, cb) { + spyOn(jQuery, 'get').and.callFake(function(req, cb) { return cb(window.emojiMenu); }); + spyOn(jQuery, 'cookie'); + }); + afterEach(function() { + // restore original url root value + gon.relative_url_root = urlRoot; }); describe('::showEmojiMenu', function() { it('should show emoji menu when Add emoji button clicked', function(done) { @@ -143,6 +144,74 @@ return expect($votesBlock.find('[data-emoji=fire]').length).toBe(0); }); }); + describe('::addYouToUserList', function() { + it('should prepend "You" to the award tooltip', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('You, sam, jerry, max, and andy'); + }); + return it('handles the special case where "You" is not cleanly comma seperated', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'sam'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('You and sam'); + }); + }); + describe('::removeYouToUserList', function() { + it('removes "You" from the front of the tooltip', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy'); + $thumbsUpEmoji.addClass('active'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('sam, jerry, max, and andy'); + }); + return it('handles the special case where "You" is not cleanly comma seperated', function() { + var $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji.attr('data-title', 'You and sam'); + $thumbsUpEmoji.addClass('active'); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + $thumbsUpEmoji.tooltip(); + return expect($thumbsUpEmoji.data("original-title")).toBe('sam'); + }); + }); + describe('::addEmojiToFrequentlyUsedList', function() { + it('should set a cookie with the correct default path', function() { + gon.relative_url_root = ''; + awardsHandler.addEmojiToFrequentlyUsedList('sunglasses'); + expect(jQuery.cookie) + .toHaveBeenCalledWith('frequently_used_emojis', 'sunglasses', { + path: '/', + expires: 365 + }) + ; + }); + it('should set a cookie with the correct custom root path', function() { + gon.relative_url_root = '/gitlab/subdir'; + awardsHandler.addEmojiToFrequentlyUsedList('alien'); + expect(jQuery.cookie) + .toHaveBeenCalledWith('frequently_used_emojis', 'alien', { + path: '/gitlab/subdir', + expires: 365 + }) + ; + }); + }); describe('search', function() { return it('should filter the emoji', function() { $('.js-add-award').eq(0).click(); diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js index 4c52ecd903d..13babb5bfdb 100644 --- a/spec/javascripts/behaviors/quick_submit_spec.js +++ b/spec/javascripts/behaviors/quick_submit_spec.js @@ -8,6 +8,7 @@ beforeEach(function() { fixture.load('behaviors/quick_submit.html'); $('form').submit(function(e) { + // Prevent a form submit from moving us off the testing page return e.preventDefault(); }); return this.spies = { @@ -38,6 +39,8 @@ expect($('input[type=submit]')).toBeDisabled(); return expect($('button[type=submit]')).toBeDisabled(); }); + // We cannot stub `navigator.userAgent` for CI's `rake teaspoon` task, so we'll + // only run the tests that apply to the current platform if (navigator.userAgent.match(/Macintosh/)) { it('responds to Meta+Enter', function() { $('input.quick-submit-input').trigger(keydownEvent()); diff --git a/spec/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js.es6 new file mode 100644 index 00000000000..15c305ce321 --- /dev/null +++ b/spec/javascripts/boards/boards_store_spec.js.es6 @@ -0,0 +1,164 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +(() => { + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board', '1'); + gl.issueBoards.BoardsStore.create(); + + $.cookie('issue_board_welcome_hidden', 'false'); + }); + + describe('Store', () => { + it('starts with a blank state', () => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + }); + + describe('lists', () => { + it('creates new list without persisting to DB', () => { + gl.issueBoards.BoardsStore.addList(listObj); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + }); + + it('finds list by ID', () => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('id', 1); + + expect(list.id).toBe(1); + }); + + it('finds list by type', () => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('type', 'label'); + + expect(list).toBeDefined(); + }); + + it('finds list limited by type', () => { + gl.issueBoards.BoardsStore.addList({ + id: 1, + position: 0, + title: 'Test', + list_type: 'backlog' + }); + const list = gl.issueBoards.BoardsStore.findList('id', 1, 'backlog'); + + expect(list).toBeDefined(); + }); + + it('gets issue when new list added', (done) => { + gl.issueBoards.BoardsStore.addList(listObj); + const list = gl.issueBoards.BoardsStore.findList('id', 1); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + setTimeout(() => { + expect(list.issues.length).toBe(1); + expect(list.issues[0].id).toBe(1); + done(); + }, 0); + }); + + it('persists new list', (done) => { + gl.issueBoards.BoardsStore.new({ + title: 'Test', + type: 'label', + label: { + id: 1, + title: 'Testing', + color: 'red', + description: 'testing;' + } + }); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + setTimeout(() => { + const list = gl.issueBoards.BoardsStore.findList('id', 1); + expect(list).toBeDefined(); + expect(list.id).toBe(1); + expect(list.position).toBe(0); + done(); + }, 0); + }); + + it('check for blank state adding', () => { + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true); + }); + + it('check for blank state not adding', () => { + gl.issueBoards.BoardsStore.addList(listObj); + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(false); + }); + + it('check for blank state adding when backlog & done list exist', () => { + gl.issueBoards.BoardsStore.addList({ + list_type: 'backlog' + }); + gl.issueBoards.BoardsStore.addList({ + list_type: 'done' + }); + + expect(gl.issueBoards.BoardsStore.shouldAddBlankState()).toBe(true); + }); + + it('adds the blank state', () => { + gl.issueBoards.BoardsStore.addBlankState(); + + const list = gl.issueBoards.BoardsStore.findList('type', 'blank', 'blank'); + expect(list).toBeDefined(); + }); + + it('removes list from state', () => { + gl.issueBoards.BoardsStore.addList(listObj); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + gl.issueBoards.BoardsStore.removeList(1, 'label'); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + }); + + it('moves the position of lists', () => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj), + listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + gl.issueBoards.BoardsStore.moveList(listOne, ['2', '1']); + + expect(listOne.position).toBe(1); + }); + + it('moves an issue from one list to another', (done) => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj), + listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + setTimeout(() => { + expect(listOne.issues.length).toBe(1); + expect(listTwo.issues.length).toBe(1); + + gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1)); + + expect(listOne.issues.length).toBe(0); + expect(listTwo.issues.length).toBe(1); + + done(); + }, 0); + }); + }); + }); +})(); diff --git a/spec/javascripts/boards/issue_spec.js.es6 b/spec/javascripts/boards/issue_spec.js.es6 new file mode 100644 index 00000000000..328c6f82ab5 --- /dev/null +++ b/spec/javascripts/boards/issue_spec.js.es6 @@ -0,0 +1,83 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +describe('Issue model', () => { + let issue; + + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board', '1'); + gl.issueBoards.BoardsStore.create(); + + issue = new ListIssue({ + title: 'Testing', + iid: 1, + confidential: false, + labels: [{ + id: 1, + title: 'test', + color: 'red', + description: 'testing' + }] + }); + }); + + it('has label', () => { + expect(issue.labels.length).toBe(1); + }); + + it('add new label', () => { + issue.addLabel({ + id: 2, + title: 'bug', + color: 'blue', + description: 'bugs!' + }); + expect(issue.labels.length).toBe(2); + }); + + it('does not add existing label', () => { + issue.addLabel({ + id: 2, + title: 'test', + color: 'blue', + description: 'bugs!' + }); + + expect(issue.labels.length).toBe(1); + }); + + it('finds label', () => { + const label = issue.findLabel(issue.labels[0]); + expect(label).toBeDefined(); + }); + + it('removes label', () => { + const label = issue.findLabel(issue.labels[0]); + issue.removeLabel(label); + expect(issue.labels.length).toBe(0); + }); + + it('removes multiple labels', () => { + issue.addLabel({ + id: 2, + title: 'bug', + color: 'blue', + description: 'bugs!' + }); + expect(issue.labels.length).toBe(2); + + issue.removeLabels([issue.labels[0], issue.labels[1]]); + expect(issue.labels.length).toBe(0); + }); +}); diff --git a/spec/javascripts/boards/list_spec.js.es6 b/spec/javascripts/boards/list_spec.js.es6 new file mode 100644 index 00000000000..ec78d82e919 --- /dev/null +++ b/spec/javascripts/boards/list_spec.js.es6 @@ -0,0 +1,80 @@ +//= require jquery +//= require jquery_ujs +//= require jquery.cookie +//= require vue +//= require vue-resource +//= require lib/utils/url_utility +//= require boards/models/issue +//= require boards/models/label +//= require boards/models/list +//= require boards/models/user +//= require boards/services/board_service +//= require boards/stores/boards_store +//= require ./mock_data + +describe('List model', () => { + let list; + + beforeEach(() => { + gl.boardService = new BoardService('/test/issue-boards/board', '1'); + gl.issueBoards.BoardsStore.create(); + + list = new List(listObj); + }); + + it('gets issues when created', (done) => { + setTimeout(() => { + expect(list.issues.length).toBe(1); + done(); + }, 0); + }); + + it('saves list and returns ID', (done) => { + list = new List({ + title: 'test', + label: { + id: 1, + title: 'test', + color: 'red' + } + }); + list.save(); + + setTimeout(() => { + expect(list.id).toBe(1); + expect(list.type).toBe('label'); + expect(list.position).toBe(0); + done(); + }, 0); + }); + + it('destroys the list', (done) => { + gl.issueBoards.BoardsStore.addList(listObj); + list = gl.issueBoards.BoardsStore.findList('id', 1); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + list.destroy(); + + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(0); + done(); + }, 0); + }); + + it('gets issue from list', (done) => { + setTimeout(() => { + const issue = list.findIssue(1); + expect(issue).toBeDefined(); + done(); + }, 0); + }); + + it('removes issue', (done) => { + setTimeout(() => { + const issue = list.findIssue(1); + expect(list.issues.length).toBe(1); + list.removeIssue(issue); + expect(list.issues.length).toBe(0); + done(); + }, 0); + }); +}); diff --git a/spec/javascripts/boards/mock_data.js.es6 b/spec/javascripts/boards/mock_data.js.es6 new file mode 100644 index 00000000000..052455f2ca6 --- /dev/null +++ b/spec/javascripts/boards/mock_data.js.es6 @@ -0,0 +1,56 @@ +const listObj = { + id: 1, + position: 0, + title: 'Test', + list_type: 'label', + label: { + id: 1, + title: 'Testing', + color: 'red', + description: 'testing;' + } +}; + +const listObjDuplicate = { + id: 2, + position: 1, + title: 'Test', + list_type: 'label', + label: { + id: 2, + title: 'Testing', + color: 'red', + description: 'testing;' + } +}; + +const BoardsMockData = { + 'GET': { + '/test/issue-boards/board/1/lists{/id}/issues': { + issues: [{ + title: 'Testing', + iid: 1, + confidential: false, + labels: [] + }], + size: 1 + } + }, + 'POST': { + '/test/issue-boards/board/1/lists{/id}': listObj + }, + 'PUT': { + '/test/issue-boards/board/1/lists{/id}': {} + }, + 'DELETE': { + '/test/issue-boards/board/1/lists{/id}': {} + } +}; + +Vue.http.interceptors.push((request, next) => { + const body = BoardsMockData[request.method][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200 + })); +}); diff --git a/spec/javascripts/datetime_utility_spec.js.es6 b/spec/javascripts/datetime_utility_spec.js.es6 new file mode 100644 index 00000000000..a2d1b0a7732 --- /dev/null +++ b/spec/javascripts/datetime_utility_spec.js.es6 @@ -0,0 +1,64 @@ +//= require lib/utils/datetime_utility +(() => { + describe('Date time utils', () => { + describe('get day name', () => { + it('should return Sunday', () => { + const day = gl.utils.getDayName(new Date('07/17/2016')); + expect(day).toBe('Sunday'); + }); + + it('should return Monday', () => { + const day = gl.utils.getDayName(new Date('07/18/2016')); + expect(day).toBe('Monday'); + }); + + it('should return Tuesday', () => { + const day = gl.utils.getDayName(new Date('07/19/2016')); + expect(day).toBe('Tuesday'); + }); + + it('should return Wednesday', () => { + const day = gl.utils.getDayName(new Date('07/20/2016')); + expect(day).toBe('Wednesday'); + }); + + it('should return Thursday', () => { + const day = gl.utils.getDayName(new Date('07/21/2016')); + expect(day).toBe('Thursday'); + }); + + it('should return Friday', () => { + const day = gl.utils.getDayName(new Date('07/22/2016')); + expect(day).toBe('Friday'); + }); + + it('should return Saturday', () => { + const day = gl.utils.getDayName(new Date('07/23/2016')); + expect(day).toBe('Saturday'); + }); + }); + + describe('get day difference', () => { + it('should return 7', () => { + const firstDay = new Date('07/01/2016'); + const secondDay = new Date('07/08/2016'); + const difference = gl.utils.getDayDifference(firstDay, secondDay); + expect(difference).toBe(7); + }); + + it('should return 31', () => { + const firstDay = new Date('07/01/2016'); + const secondDay = new Date('08/01/2016'); + const difference = gl.utils.getDayDifference(firstDay, secondDay); + expect(difference).toBe(31); + }); + + it('should return 365', () => { + const firstDay = new Date('07/02/2015'); + const secondDay = new Date('07/01/2016'); + const difference = gl.utils.getDayDifference(firstDay, secondDay); + expect(difference).toBe(365); + }); + }); + }); +})(); diff --git a/spec/javascripts/diff_comments_store_spec.js.es6 b/spec/javascripts/diff_comments_store_spec.js.es6 new file mode 100644 index 00000000000..22293d4de87 --- /dev/null +++ b/spec/javascripts/diff_comments_store_spec.js.es6 @@ -0,0 +1,122 @@ +//= require vue +//= require diff_notes/models/discussion +//= require diff_notes/models/note +//= require diff_notes/stores/comments +(() => { + function createDiscussion(noteId = 1, resolved = true) { + CommentsStore.create('a', noteId, true, resolved, 'test'); + }; + + beforeEach(() => { + CommentsStore.state = {}; + }); + + describe('New discussion', () => { + it('creates new discussion', () => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + expect(Object.keys(CommentsStore.state).length).toBe(1); + }); + + it('creates new note in discussion', () => { + createDiscussion(); + createDiscussion(2); + + const discussion = CommentsStore.state['a']; + expect(Object.keys(discussion.notes).length).toBe(2); + }); + }); + + describe('Get note', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('gets note by ID', () => { + const note = CommentsStore.get('a', 1); + expect(note).toBeDefined(); + expect(note.id).toBe(1); + }); + }); + + describe('Delete discussion', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('deletes discussion by ID', () => { + CommentsStore.delete('a', 1); + expect(Object.keys(CommentsStore.state).length).toBe(0); + }); + + it('deletes discussion when no more notes', () => { + createDiscussion(); + createDiscussion(2); + expect(Object.keys(CommentsStore.state).length).toBe(1); + expect(Object.keys(CommentsStore.state['a'].notes).length).toBe(2); + + CommentsStore.delete('a', 1); + CommentsStore.delete('a', 2); + expect(Object.keys(CommentsStore.state).length).toBe(0); + }); + }); + + describe('Update note', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('updates note to be unresolved', () => { + CommentsStore.update('a', 1, false, 'test'); + + const note = CommentsStore.get('a', 1); + expect(note.resolved).toBe(false); + }); + }); + + describe('Discussion resolved', () => { + beforeEach(() => { + expect(Object.keys(CommentsStore.state).length).toBe(0); + createDiscussion(); + }); + + it('is resolved with single note', () => { + const discussion = CommentsStore.state['a']; + expect(discussion.isResolved()).toBe(true); + }); + + it('is unresolved with 2 notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2, false); + console.log(discussion.isResolved()); + + expect(discussion.isResolved()).toBe(false); + }); + + it('is resolved with 2 notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2); + + expect(discussion.isResolved()).toBe(true); + }); + + it('resolve all notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2, false); + + discussion.resolveAllNotes(); + expect(discussion.isResolved()).toBe(true); + }); + + it('unresolve all notes', () => { + const discussion = CommentsStore.state['a']; + createDiscussion(2); + + discussion.unResolveAllNotes(); + expect(discussion.isResolved()).toBe(false); + }); + }); +})(); diff --git a/spec/javascripts/fixtures/abuse_reports.html.haml b/spec/javascripts/fixtures/abuse_reports.html.haml new file mode 100644 index 00000000000..2ec302abcb7 --- /dev/null +++ b/spec/javascripts/fixtures/abuse_reports.html.haml @@ -0,0 +1,16 @@ +.abuse-reports + .message#long + Cat ipsum dolor sit amet, hide head under blanket so no one can see. + Gate keepers of hell eat and than sleep on your face but hunt by meowing + loudly at 5am next to human slave food dispenser cats go for world + domination or chase laser, yet poop on grasses chirp at birds. Cat is love, + cat is life chase after silly colored fish toys around the house climb a + tree, wait for a fireman jump to fireman then scratch his face fall asleep + on the washing machine lies down always hungry so caticus cuteicus. Sit on + human. Spot something, big eyes, big eyes, crouch, shake butt, prepare to + pounce sleep in the bathroom sink hiss at vacuum cleaner hide head under + blanket so no one can see throwup on your pillow. + .message#short + Cat ipsum dolor sit amet, groom yourself 4 hours - checked, have your + beauty sleep 18 hours - checked, be fabulous for the rest of the day - + checked! for shake treat bag. diff --git a/spec/javascripts/fixtures/awards_handler.html.haml b/spec/javascripts/fixtures/awards_handler.html.haml index d55936ee4f9..1ef2e8f8624 100644 --- a/spec/javascripts/fixtures/awards_handler.html.haml +++ b/spec/javascripts/fixtures/awards_handler.html.haml @@ -39,7 +39,7 @@ %span.note-role Reporter %a.note-action-button.note-emoji-button.js-add-award.js-note-emoji{"data-position" => "right", :href => "#", :title => "Award Emoji"} %i.fa.fa-spinner.fa-spin - %i.fa.fa-smile-o + %i.fa.fa-smile-o.link-highlight .js-task-list-container.note-body.is-task-list-enabled .note-text %p Suscipit sunt quia quisquam sed eveniet ipsam. diff --git a/spec/javascripts/fixtures/comments.html.haml b/spec/javascripts/fixtures/comments.html.haml new file mode 100644 index 00000000000..cc1f8f15c21 --- /dev/null +++ b/spec/javascripts/fixtures/comments.html.haml @@ -0,0 +1,21 @@ +.flash-container.timeline-content +.timeline-icon.hidden-xs.hidden-sm + %a.author_link + %img +.timeline-content.timeline-content-form + %form.new-note.js-quick-submit.common-note-form.gfm-form.js-main-target-form + .md-area + .md-header + .md-write-holder + .zen-backdrop.div-dropzone-wrapper + .div-dropzone-wrapper + .div-dropzone.dz-clickable + %textarea.note-textarea.js-note-text.js-gfm-input.js-autosize.markdown-area + .note-form-actions.clearfix + %input.btn.btn-nr.btn-create.append-right-10.comment-btn.js-comment-button{ type: 'submit' } + %a.btn.btn-nr.btn-reopen.btn-comment.js-note-target-reopen + Reopen issue + %a.btn.btn-nr.btn-close.btn-comment.js-note-target-close + Close issue + %a.btn.btn-cancel.js-note-discard + Discard draft
\ No newline at end of file diff --git a/spec/javascripts/fixtures/event_filter.html.haml b/spec/javascripts/fixtures/event_filter.html.haml new file mode 100644 index 00000000000..95e248cadf8 --- /dev/null +++ b/spec/javascripts/fixtures/event_filter.html.haml @@ -0,0 +1,21 @@ +%ul.nav-links.event-filter.scrolling-tabs + %li.active + %a.event-filter-link{ id: "all_event_filter", title: "Filter by all", href: "/dashboard/activity"} + %span + All + %li + %a.event-filter-link{ id: "push_event_filter", title: "Filter by push events", href: "/dashboard/activity"} + %span + Push events + %li + %a.event-filter-link{ id: "merged_event_filter", title: "Filter by merge events", href: "/dashboard/activity"} + %span + Merge events + %li + %a.event-filter-link{ id: "comments_event_filter", title: "Filter by comments", href: "/dashboard/activity"} + %span + Comments + %li + %a.event-filter-link{ id: "team_event_filter", title: "Filter by team", href: "/dashboard/activity"} + %span + Team
\ No newline at end of file diff --git a/spec/javascripts/fixtures/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml new file mode 100644 index 00000000000..a20390c08ee --- /dev/null +++ b/spec/javascripts/fixtures/gl_dropdown.html.haml @@ -0,0 +1,16 @@ +%div + .dropdown.inline + %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Projects + %i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Go to project + %button.dropdown-title-button.dropdown-menu-close{aria: {label: 'Close'}} + %i.fa.fa-times.dropdown-menu-close-icon + .dropdown-input + %input.dropdown-input-field{type: 'search', placeholder: 'Filter results'} + %i.fa.fa-search.dropdown-input-search + .dropdown-content + .dropdown-loading + %i.fa.fa-spinner.fa-spin diff --git a/spec/javascripts/fixtures/issue_sidebar_label.html.haml b/spec/javascripts/fixtures/issue_sidebar_label.html.haml new file mode 100644 index 00000000000..397bdc85c67 --- /dev/null +++ b/spec/javascripts/fixtures/issue_sidebar_label.html.haml @@ -0,0 +1,16 @@ +.block.labels + .sidebar-collapsed-icon.js-sidebar-labels-tooltip + .title.hide-collapsed + %a.edit-link.pull-right{ href: "#" } + Edit + .selectbox.hide-collapsed{ style: "display: none;" } + .dropdown + %button.dropdown-menu-toggle.js-label-select.js-multiselect{ type: "button", data: { ability_name: "issue", field_name: "issue[label_names][]", issue_update: "/root/test/issues/2.json", labels: "/root/test/labels.json", project_id: "12", show_any: "true", show_no: "true", toggle: "dropdown" } } + %span.dropdown-toggle-text + Label + %i.fa.fa-chevron-down + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-page-one + .dropdown-content + .dropdown-loading + %i.fa.fa-spinner.fa-spin diff --git a/spec/javascripts/fixtures/projects.json b/spec/javascripts/fixtures/projects.json index 84e8d0ba1e4..4919d77e5a4 100644 --- a/spec/javascripts/fixtures/projects.json +++ b/spec/javascripts/fixtures/projects.json @@ -1 +1 @@ -[{"id":9,"description":"","default_branch":null,"tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:root/test.git","http_url_to_repo":"http://localhost:3000/root/test.git","web_url":"http://localhost:3000/root/test","owner":{"name":"Administrator","username":"root","id":1,"state":"active","avatar_url":"http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon","web_url":"http://localhost:3000/u/root"},"name":"test","name_with_namespace":"Administrator / test","path":"test","path_with_namespace":"root/test","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-14T19:08:05.364Z","last_activity_at":"2016-01-14T19:08:07.418Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":1,"name":"root","path":"root","owner_id":1,"created_at":"2016-01-13T20:19:44.439Z","updated_at":"2016-01-13T20:19:44.439Z","description":"","avatar":null},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":0,"permissions":{"project_access":null,"group_access":null}},{"id":8,"description":"Voluptatem quae nulla eius numquam ullam voluptatibus quia modi.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:h5bp/html5-boilerplate.git","http_url_to_repo":"http://localhost:3000/h5bp/html5-boilerplate.git","web_url":"http://localhost:3000/h5bp/html5-boilerplate","name":"Html5 Boilerplate","name_with_namespace":"H5bp / Html5 Boilerplate","path":"html5-boilerplate","path_with_namespace":"h5bp/html5-boilerplate","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:57.525Z","last_activity_at":"2016-01-13T20:27:57.280Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":5,"name":"H5bp","path":"h5bp","owner_id":null,"created_at":"2016-01-13T20:19:57.239Z","updated_at":"2016-01-13T20:19:57.239Z","description":"Tempore accusantium possimus aut libero.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":5,"permissions":{"project_access":{"access_level":10,"notification_level":3},"group_access":{"access_level":50,"notification_level":3}}},{"id":7,"description":"Modi odio mollitia dolorem qui.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:twitter/typeahead-js.git","http_url_to_repo":"http://localhost:3000/twitter/typeahead-js.git","web_url":"http://localhost:3000/twitter/typeahead-js","name":"Typeahead.Js","name_with_namespace":"Twitter / Typeahead.Js","path":"typeahead-js","path_with_namespace":"twitter/typeahead-js","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:56.212Z","last_activity_at":"2016-01-13T20:27:51.496Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":4,"name":"Twitter","path":"twitter","owner_id":null,"created_at":"2016-01-13T20:19:54.480Z","updated_at":"2016-01-13T20:19:54.480Z","description":"Id voluptatem ipsa maiores omnis repudiandae et et.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":4,"permissions":{"project_access":null,"group_access":{"access_level":10,"notification_level":3}}},{"id":6,"description":"Omnis asperiores ipsa et beatae quidem necessitatibus quia.","default_branch":"master","tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:twitter/flight.git","http_url_to_repo":"http://localhost:3000/twitter/flight.git","web_url":"http://localhost:3000/twitter/flight","name":"Flight","name_with_namespace":"Twitter / Flight","path":"flight","path_with_namespace":"twitter/flight","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:54.754Z","last_activity_at":"2016-01-13T20:27:50.502Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":4,"name":"Twitter","path":"twitter","owner_id":null,"created_at":"2016-01-13T20:19:54.480Z","updated_at":"2016-01-13T20:19:54.480Z","description":"Id voluptatem ipsa maiores omnis repudiandae et et.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":4,"permissions":{"project_access":null,"group_access":{"access_level":10,"notification_level":3}}},{"id":5,"description":"Voluptatem commodi voluptate placeat architecto beatae illum dolores fugiat.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-test.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-test.git","web_url":"http://localhost:3000/gitlab-org/gitlab-test","name":"Gitlab Test","name_with_namespace":"Gitlab Org / Gitlab Test","path":"gitlab-test","path_with_namespace":"gitlab-org/gitlab-test","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:53.202Z","last_activity_at":"2016-01-13T20:27:41.626Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":5,"permissions":{"project_access":null,"group_access":{"access_level":50,"notification_level":3}}},{"id":4,"description":"Aut molestias quas est ut aperiam officia quod libero.","default_branch":"master","tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-shell.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-shell.git","web_url":"http://localhost:3000/gitlab-org/gitlab-shell","name":"Gitlab Shell","name_with_namespace":"Gitlab Org / Gitlab Shell","path":"gitlab-shell","path_with_namespace":"gitlab-org/gitlab-shell","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:51.882Z","last_activity_at":"2016-01-13T20:27:35.678Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":5,"permissions":{"project_access":{"access_level":20,"notification_level":3},"group_access":{"access_level":50,"notification_level":3}}},{"id":3,"description":"Excepturi molestiae quia repellendus omnis est illo illum eligendi.","default_branch":"master","tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-ci.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-ci.git","web_url":"http://localhost:3000/gitlab-org/gitlab-ci","name":"Gitlab Ci","name_with_namespace":"Gitlab Org / Gitlab Ci","path":"gitlab-ci","path_with_namespace":"gitlab-org/gitlab-ci","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:50.346Z","last_activity_at":"2016-01-13T20:27:30.115Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":3,"permissions":{"project_access":null,"group_access":{"access_level":50,"notification_level":3}}},{"id":2,"description":"Adipisci quaerat dignissimos enim sed ipsam dolorem quia.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":10,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-ce.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-ce.git","web_url":"http://localhost:3000/gitlab-org/gitlab-ce","name":"Gitlab Ce","name_with_namespace":"Gitlab Org / Gitlab Ce","path":"gitlab-ce","path_with_namespace":"gitlab-org/gitlab-ce","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:49.065Z","last_activity_at":"2016-01-13T20:26:58.454Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":5,"permissions":{"project_access":{"access_level":30,"notification_level":3},"group_access":{"access_level":50,"notification_level":3}}},{"id":1,"description":"Vel voluptatem maxime saepe ex quia.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:documentcloud/underscore.git","http_url_to_repo":"http://localhost:3000/documentcloud/underscore.git","web_url":"http://localhost:3000/documentcloud/underscore","name":"Underscore","name_with_namespace":"Documentcloud / Underscore","path":"underscore","path_with_namespace":"documentcloud/underscore","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:45.862Z","last_activity_at":"2016-01-13T20:25:03.106Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":2,"name":"Documentcloud","path":"documentcloud","owner_id":null,"created_at":"2016-01-13T20:19:44.464Z","updated_at":"2016-01-13T20:19:44.464Z","description":"Aut impedit perferendis fuga et ipsa repellat cupiditate et.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"open_issues_count":5,"permissions":{"project_access":null,"group_access":{"access_level":50,"notification_level":3}}}] +[{"id":9,"description":"","default_branch":null,"tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:root/test.git","http_url_to_repo":"http://localhost:3000/root/test.git","web_url":"http://localhost:3000/root/test","owner":{"name":"Administrator","username":"root","id":1,"state":"active","avatar_url":"http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon","web_url":"http://localhost:3000/u/root"},"name":"test","name_with_namespace":"Administrator / test","path":"test","path_with_namespace":"root/test","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-14T19:08:05.364Z","last_activity_at":"2016-01-14T19:08:07.418Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":1,"name":"root","path":"root","owner_id":1,"created_at":"2016-01-13T20:19:44.439Z","updated_at":"2016-01-13T20:19:44.439Z","description":"","avatar":null},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":0,"permissions":{"project_access":null,"group_access":null}},{"id":8,"description":"Voluptatem quae nulla eius numquam ullam voluptatibus quia modi.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:h5bp/html5-boilerplate.git","http_url_to_repo":"http://localhost:3000/h5bp/html5-boilerplate.git","web_url":"http://localhost:3000/h5bp/html5-boilerplate","name":"Html5 Boilerplate","name_with_namespace":"H5bp / Html5 Boilerplate","path":"html5-boilerplate","path_with_namespace":"h5bp/html5-boilerplate","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:57.525Z","last_activity_at":"2016-01-13T20:27:57.280Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":5,"name":"H5bp","path":"h5bp","owner_id":null,"created_at":"2016-01-13T20:19:57.239Z","updated_at":"2016-01-13T20:19:57.239Z","description":"Tempore accusantium possimus aut libero.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":5,"permissions":{"project_access":{"access_level":10,"notification_level":3},"group_access":{"access_level":50,"notification_level":3}}},{"id":7,"description":"Modi odio mollitia dolorem qui.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:twitter/typeahead-js.git","http_url_to_repo":"http://localhost:3000/twitter/typeahead-js.git","web_url":"http://localhost:3000/twitter/typeahead-js","name":"Typeahead.Js","name_with_namespace":"Twitter / Typeahead.Js","path":"typeahead-js","path_with_namespace":"twitter/typeahead-js","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:56.212Z","last_activity_at":"2016-01-13T20:27:51.496Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":4,"name":"Twitter","path":"twitter","owner_id":null,"created_at":"2016-01-13T20:19:54.480Z","updated_at":"2016-01-13T20:19:54.480Z","description":"Id voluptatem ipsa maiores omnis repudiandae et et.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":true,"open_issues_count":4,"permissions":{"project_access":null,"group_access":{"access_level":10,"notification_level":3}}},{"id":6,"description":"Omnis asperiores ipsa et beatae quidem necessitatibus quia.","default_branch":"master","tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:twitter/flight.git","http_url_to_repo":"http://localhost:3000/twitter/flight.git","web_url":"http://localhost:3000/twitter/flight","name":"Flight","name_with_namespace":"Twitter / Flight","path":"flight","path_with_namespace":"twitter/flight","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:54.754Z","last_activity_at":"2016-01-13T20:27:50.502Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":4,"name":"Twitter","path":"twitter","owner_id":null,"created_at":"2016-01-13T20:19:54.480Z","updated_at":"2016-01-13T20:19:54.480Z","description":"Id voluptatem ipsa maiores omnis repudiandae et et.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":true,"open_issues_count":4,"permissions":{"project_access":null,"group_access":{"access_level":10,"notification_level":3}}},{"id":5,"description":"Voluptatem commodi voluptate placeat architecto beatae illum dolores fugiat.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-test.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-test.git","web_url":"http://localhost:3000/gitlab-org/gitlab-test","name":"Gitlab Test","name_with_namespace":"Gitlab Org / Gitlab Test","path":"gitlab-test","path_with_namespace":"gitlab-org/gitlab-test","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:53.202Z","last_activity_at":"2016-01-13T20:27:41.626Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":5,"permissions":{"project_access":null,"group_access":{"access_level":50,"notification_level":3}}},{"id":4,"description":"Aut molestias quas est ut aperiam officia quod libero.","default_branch":"master","tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-shell.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-shell.git","web_url":"http://localhost:3000/gitlab-org/gitlab-shell","name":"Gitlab Shell","name_with_namespace":"Gitlab Org / Gitlab Shell","path":"gitlab-shell","path_with_namespace":"gitlab-org/gitlab-shell","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:51.882Z","last_activity_at":"2016-01-13T20:27:35.678Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":5,"permissions":{"project_access":{"access_level":20,"notification_level":3},"group_access":{"access_level":50,"notification_level":3}}},{"id":3,"description":"Excepturi molestiae quia repellendus omnis est illo illum eligendi.","default_branch":"master","tag_list":[],"public":true,"archived":false,"visibility_level":20,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-ci.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-ci.git","web_url":"http://localhost:3000/gitlab-org/gitlab-ci","name":"Gitlab Ci","name_with_namespace":"Gitlab Org / Gitlab Ci","path":"gitlab-ci","path_with_namespace":"gitlab-org/gitlab-ci","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:50.346Z","last_activity_at":"2016-01-13T20:27:30.115Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":3,"permissions":{"project_access":null,"group_access":{"access_level":50,"notification_level":3}}},{"id":2,"description":"Adipisci quaerat dignissimos enim sed ipsam dolorem quia.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":10,"ssh_url_to_repo":"phil@localhost:gitlab-org/gitlab-ce.git","http_url_to_repo":"http://localhost:3000/gitlab-org/gitlab-ce.git","web_url":"http://localhost:3000/gitlab-org/gitlab-ce","name":"Gitlab Ce","name_with_namespace":"Gitlab Org / Gitlab Ce","path":"gitlab-ce","path_with_namespace":"gitlab-org/gitlab-ce","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:49.065Z","last_activity_at":"2016-01-13T20:26:58.454Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":3,"name":"Gitlab Org","path":"gitlab-org","owner_id":null,"created_at":"2016-01-13T20:19:48.851Z","updated_at":"2016-01-13T20:19:48.851Z","description":"Magni mollitia quod quidem soluta nesciunt impedit.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":5,"permissions":{"project_access":{"access_level":30,"notification_level":3},"group_access":{"access_level":50,"notification_level":3}}},{"id":1,"description":"Vel voluptatem maxime saepe ex quia.","default_branch":"master","tag_list":[],"public":false,"archived":false,"visibility_level":0,"ssh_url_to_repo":"phil@localhost:documentcloud/underscore.git","http_url_to_repo":"http://localhost:3000/documentcloud/underscore.git","web_url":"http://localhost:3000/documentcloud/underscore","name":"Underscore","name_with_namespace":"Documentcloud / Underscore","path":"underscore","path_with_namespace":"documentcloud/underscore","issues_enabled":true,"merge_requests_enabled":true,"wiki_enabled":true,"builds_enabled":true,"snippets_enabled":false,"created_at":"2016-01-13T20:19:45.862Z","last_activity_at":"2016-01-13T20:25:03.106Z","shared_runners_enabled":true,"creator_id":1,"namespace":{"id":2,"name":"Documentcloud","path":"documentcloud","owner_id":null,"created_at":"2016-01-13T20:19:44.464Z","updated_at":"2016-01-13T20:19:44.464Z","description":"Aut impedit perferendis fuga et ipsa repellat cupiditate et.","avatar":{"url":null}},"avatar_url":null,"star_count":0,"forks_count":0,"only_allow_merge_if_build_succeeds":false,"open_issues_count":5,"permissions":{"project_access":null,"group_access":{"access_level":50,"notification_level":3}}}] diff --git a/spec/javascripts/fixtures/u2f/authenticate.html.haml b/spec/javascripts/fixtures/u2f/authenticate.html.haml index 859e79a6c9e..779d6429a5f 100644 --- a/spec/javascripts/fixtures/u2f/authenticate.html.haml +++ b/spec/javascripts/fixtures/u2f/authenticate.html.haml @@ -1 +1 @@ -= render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in" } += render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in", params: {}, resource_name: "user" } diff --git a/spec/javascripts/gl_dropdown_spec.js.es6 b/spec/javascripts/gl_dropdown_spec.js.es6 new file mode 100644 index 00000000000..b529ea6458d --- /dev/null +++ b/spec/javascripts/gl_dropdown_spec.js.es6 @@ -0,0 +1,119 @@ +/*= require jquery */ +/*= require gl_dropdown */ +/*= require turbolinks */ +/*= require lib/utils/common_utils */ +/*= require lib/utils/type_utility */ + +(() => { + const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'; + const ITEM_SELECTOR = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; + const FOCUSED_ITEM_SELECTOR = `${ITEM_SELECTOR} a.is-focused`; + + const ARROW_KEYS = { + DOWN: 40, + UP: 38, + ENTER: 13, + ESC: 27 + }; + + let navigateWithKeys = function navigateWithKeys(direction, steps, cb, i) { + i = i || 0; + if (!i) direction = direction.toUpperCase(); + $('body').trigger({ + type: 'keydown', + which: ARROW_KEYS[direction], + keyCode: ARROW_KEYS[direction] + }); + i++; + if (i <= steps) { + navigateWithKeys(direction, steps, cb, i); + } else { + cb(); + } + }; + + describe('Dropdown', function describeDropdown() { + fixture.preload('gl_dropdown.html'); + fixture.preload('projects.json'); + + beforeEach(() => { + fixture.load('gl_dropdown.html'); + this.dropdownContainerElement = $('.dropdown.inline'); + this.dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement); + this.projectsData = fixture.load('projects.json')[0]; + this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown({ + selectable: true, + data: this.projectsData, + text: (project) => { + (project.name_with_namespace || project.name); + }, + id: (project) => { + project.id; + } + }); + }); + + afterEach(() => { + $('body').unbind('keydown'); + this.dropdownContainerElement.unbind('keyup'); + }); + + it('should open on click', () => { + expect(this.dropdownContainerElement).not.toHaveClass('open'); + this.dropdownButtonElement.click(); + expect(this.dropdownContainerElement).toHaveClass('open'); + }); + + describe('that is open', () => { + beforeEach(() => { + this.dropdownButtonElement.click(); + }); + + it('should select a following item on DOWN keypress', () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0); + let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 1)) + 0); + navigateWithKeys('down', randomIndex, () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1); + expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement)).toHaveClass('is-focused'); + }); + }); + + it('should select a previous item on UP keypress', () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0); + navigateWithKeys('down', (this.projectsData.length - 1), () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1); + let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 2)) + 0); + navigateWithKeys('up', randomIndex, () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1); + expect($(`${ITEM_SELECTOR}:eq(${((this.projectsData.length - 2) - randomIndex)}) a`, this.dropdownMenuElement)).toHaveClass('is-focused'); + }); + }); + }); + + it('should click the selected item on ENTER keypress', () => { + expect(this.dropdownContainerElement).toHaveClass('open') + let randomIndex = Math.floor(Math.random() * (this.projectsData.length - 1)) + 0 + navigateWithKeys('down', randomIndex, () => { + spyOn(Turbolinks, 'visit').and.stub(); + navigateWithKeys('enter', null, () => { + expect(this.dropdownContainerElement).not.toHaveClass('open'); + let link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement); + expect(link).toHaveClass('is-active'); + let linkedLocation = link.attr('href'); + if (linkedLocation && linkedLocation !== '#') expect(Turbolinks.visit).toHaveBeenCalledWith(linkedLocation); + }); + }); + }); + + it('should close on ESC keypress', () => { + expect(this.dropdownContainerElement).toHaveClass('open'); + this.dropdownContainerElement.trigger({ + type: 'keyup', + which: ARROW_KEYS.ESC, + keyCode: ARROW_KEYS.ESC + }); + expect(this.dropdownContainerElement).not.toHaveClass('open'); + }); + }); + }); +})(); diff --git a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js index 82ee1954a59..d5401fbb0d1 100644 --- a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js +++ b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js @@ -7,7 +7,7 @@ describe("ContributorsGraph", function () { expect(ContributorsGraph.prototype.x_domain).toEqual(20) }) }) - + describe("#set_y_domain", function () { it("sets the y_domain", function () { ContributorsGraph.set_y_domain([{commits: 30}]) @@ -89,7 +89,7 @@ describe("ContributorsGraph", function () { }) describe("ContributorsMasterGraph", function () { - + // TODO: fix or remove //describe("#process_dates", function () { //it("gets and parses dates", function () { @@ -103,7 +103,7 @@ describe("ContributorsMasterGraph", function () { //expect(graph.get_dates).toHaveBeenCalledWith(data) //expect(ContributorsGraph.set_dates).toHaveBeenCalledWith("get") //}) - //}) + //}) describe("#get_dates", function () { it("plucks the date field from data collection", function () { @@ -124,5 +124,5 @@ describe("ContributorsMasterGraph", function () { }) }) - + }) diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index dc6231ebb38..33690c7a5f3 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,7 +1,5 @@ /*= require lib/utils/text_utility */ - - /*= require issue */ (function() { diff --git a/spec/javascripts/labels_issue_sidebar_spec.js.es6 b/spec/javascripts/labels_issue_sidebar_spec.js.es6 new file mode 100644 index 00000000000..1ad6f612210 --- /dev/null +++ b/spec/javascripts/labels_issue_sidebar_spec.js.es6 @@ -0,0 +1,88 @@ +//= require lib/utils/type_utility +//= require jquery +//= require bootstrap +//= require gl_dropdown +//= require select2 +//= require jquery.nicescroll +//= require api +//= require create_label +//= require issuable_context +//= require users_select +//= require labels_select + +(() => { + let saveLabelCount = 0; + describe('Issue dropdown sidebar', () => { + fixture.preload('issue_sidebar_label.html'); + + beforeEach(() => { + fixture.load('issue_sidebar_label.html'); + new IssuableContext('{"id":1,"name":"Administrator","username":"root"}'); + new LabelsSelect(); + + spyOn(jQuery, 'ajax').and.callFake((req) => { + const d = $.Deferred(); + let LABELS_DATA = [] + + if (req.url === '/root/test/labels.json') { + for (let i = 0; i < 10; i++) { + LABELS_DATA.push({id: i, title: `test ${i}`, color: '#5CB85C'}); + } + } else if (req.url === '/root/test/issues/2.json') { + let tmp = [] + for (let i = 0; i < saveLabelCount; i++) { + tmp.push({id: i, title: `test ${i}`, color: '#5CB85C'}); + } + LABELS_DATA = {labels: tmp}; + } + + d.resolve(LABELS_DATA); + return d.promise(); + }); + }); + + it('changes collapsed tooltip when changing labels when less than 5', (done) => { + saveLabelCount = 5; + $('.edit-link').get(0).click(); + + setTimeout(() => { + expect($('.dropdown-content a').length).toBe(10); + + $('.dropdown-content a').each(function (i) { + if (i < saveLabelCount) { + $(this).get(0).click(); + } + }); + + $('.edit-link').get(0).click(); + + setTimeout(() => { + expect($('.sidebar-collapsed-icon').attr('data-original-title')).toBe('test 0, test 1, test 2, test 3, test 4'); + done(); + }, 0); + }, 0); + }); + + it('changes collapsed tooltip when changing labels when more than 5', (done) => { + saveLabelCount = 6; + $('.edit-link').get(0).click(); + + setTimeout(() => { + expect($('.dropdown-content a').length).toBe(10); + + $('.dropdown-content a').each(function (i) { + if (i < saveLabelCount) { + $(this).get(0).click(); + } + }); + + $('.edit-link').get(0).click(); + + setTimeout(() => { + expect($('.sidebar-collapsed-icon').attr('data-original-title')).toBe('test 0, test 1, test 2, test 3, test 4, and 1 more'); + done(); + }, 0); + }, 0); + }); + }); +})(); diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js index 17b32914ec3..c9175e2b704 100644 --- a/spec/javascripts/merge_request_widget_spec.js +++ b/spec/javascripts/merge_request_widget_spec.js @@ -1,5 +1,5 @@ - /*= require merge_request_widget */ +/*= require lib/utils/jquery.timeago.js */ (function() { describe('MergeRequestWidget', function() { @@ -8,6 +8,7 @@ window.notify = function() {}; this.opts = { ci_status_url: "http://sampledomain.local/ci/getstatus", + ci_environments_status_url: "http://sampledomain.local/ci/getenvironmentsstatus", ci_status: "", ci_message: { normal: "Build {{status}} for \"{{title}}\"", @@ -20,17 +21,48 @@ gitlab_icon: "gitlab_logo.png", builds_path: "http://sampledomain.local/sampleBuildsPath" }; - this["class"] = new MergeRequestWidget(this.opts); - return this.ciStatusData = { - "title": "Sample MR title", - "sha": "12a34bc5", - "status": "success", - "coverage": 98 - }; + this["class"] = new window.gl.MergeRequestWidget(this.opts); }); + + describe('getCIEnvironmentsStatus', function() { + beforeEach(function() { + this.ciEnvironmentsStatusData = [{ + created_at: '2016-09-12T13:38:30.636Z', + environment_id: 1, + environment_name: 'env1', + external_url: 'https://test-url.com', + external_url_formatted: 'test-url.com' + }]; + + spyOn(jQuery, 'getJSON').and.callFake((req, cb) => { + cb(this.ciEnvironmentsStatusData); + }); + }); + + it('should call renderEnvironments when the environments property is set', function() { + const spy = spyOn(this.class, 'renderEnvironments').and.stub(); + this.class.getCIEnvironmentsStatus(); + expect(spy).toHaveBeenCalledWith(this.ciEnvironmentsStatusData); + }); + + it('should not call renderEnvironments when the environments property is not set', function() { + this.ciEnvironmentsStatusData = null; + const spy = spyOn(this.class, 'renderEnvironments').and.stub(); + this.class.getCIEnvironmentsStatus(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + return describe('getCIStatus', function() { beforeEach(function() { - return spyOn(jQuery, 'getJSON').and.callFake((function(_this) { + this.ciStatusData = { + "title": "Sample MR title", + "sha": "12a34bc5", + "status": "success", + "coverage": 98 + }; + + spyOn(jQuery, 'getJSON').and.callFake((function(_this) { return function(req, cb) { return cb(_this.ciStatusData); }; @@ -61,10 +93,10 @@ this["class"].getCIStatus(false); return expect(spy).not.toHaveBeenCalled(); }); - return it('should not display a notification on the first check after the widget has been created', function() { + it('should not display a notification on the first check after the widget has been created', function() { var spy; spy = spyOn(window, 'notify'); - this["class"] = new MergeRequestWidget(this.opts); + this["class"] = new window.gl.MergeRequestWidget(this.opts); this["class"].getCIStatus(true); return expect(spy).not.toHaveBeenCalled(); }); diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js index 25d3f5b6c04..f09596bd36d 100644 --- a/spec/javascripts/new_branch_spec.js +++ b/spec/javascripts/new_branch_spec.js @@ -1,7 +1,5 @@ /*= require jquery-ui/autocomplete */ - - /*= require new_branch_form */ (function() { diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 14dc6bfdfde..a588f403dd5 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -1,8 +1,7 @@ - /*= require notes */ - - +/*= require autosize */ /*= require gl_form */ +/*= require lib/utils/text_utility */ (function() { window.gon || (window.gon = {}); @@ -12,29 +11,63 @@ }; describe('Notes', function() { - return describe('task lists', function() { + describe('task lists', function() { fixture.preload('issue_note.html'); + beforeEach(function() { fixture.load('issue_note.html'); $('form').on('submit', function(e) { - return e.preventDefault(); + e.preventDefault(); }); - return this.notes = new Notes(); + this.notes = new Notes(); }); + it('modifies the Markdown field', function() { $('input[type=checkbox]').attr('checked', true).trigger('change'); - return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); - return it('submits the form on tasklist:changed', function() { - var submitted; - submitted = false; + + it('submits the form on tasklist:changed', function() { + var submitted = false; $('form').on('submit', function(e) { submitted = true; - return e.preventDefault(); + e.preventDefault(); }); + $('.js-task-list-field').trigger('tasklist:changed'); - return expect(submitted).toBe(true); + expect(submitted).toBe(true); + }); + }); + + describe('comments', function() { + var commentsTemplate = 'comments.html'; + var textarea = '.js-note-text'; + fixture.preload(commentsTemplate); + + beforeEach(function() { + fixture.load(commentsTemplate); + this.notes = new Notes(); + + this.autoSizeSpy = spyOnEvent($(textarea), 'autosize:update'); + spyOn(this.notes, 'renderNote').and.stub(); + + $(textarea).data('autosave', { + reset: function() {} + }); + + $('form').on('submit', function(e) { + e.preventDefault(); + $('.js-main-target-form').trigger('ajax:success'); + }); }); + + it('autosizes after comment submission', function() { + $(textarea).text('This is an example comment note'); + expect(this.autoSizeSpy).not.toHaveBeenTriggered(); + + $('.js-comment-button').click(); + expect(this.autoSizeSpy).toHaveBeenTriggered(); + }) }); }); diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index ffe49828492..51eb12b41d4 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -1,22 +1,10 @@ /*= require bootstrap */ - - /*= require select2 */ - - /*= require lib/utils/type_utility */ - - /*= require gl_dropdown */ - - /*= require api */ - - /*= require project_select */ - - /*= require project */ (function() { diff --git a/spec/javascripts/right_sidebar_spec.js b/spec/javascripts/right_sidebar_spec.js index 38b3b2653ec..c937a4706f7 100644 --- a/spec/javascripts/right_sidebar_spec.js +++ b/spec/javascripts/right_sidebar_spec.js @@ -1,10 +1,6 @@ /*= require right_sidebar */ - - /*= require jquery */ - - /*= require jquery.cookie */ (function() { diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index 68d64483d67..333128782a2 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -1,20 +1,12 @@ /*= require gl_dropdown */ - - /*= require search_autocomplete */ - - /*= require jquery */ - - /*= require lib/utils/common_utils */ - - /*= require lib/utils/type_utility */ - - /*= require fuzzaldrin-plus */ +/*= require turbolinks */ +/*= require jquery.turbolinks */ (function() { var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget; @@ -43,6 +35,8 @@ groupName = 'Gitlab Org'; + // Add required attributes to body before starting the test. + // section would be dashboard|group|project addBodyAttributes = function(section) { var $body; if (section == null) { @@ -64,6 +58,7 @@ } }; + // Mock `gl` object in window for dashboard specific page. App code will need it. mockDashboardOptions = function() { window.gl || (window.gl = {}); return window.gl.dashboardOptions = { @@ -72,6 +67,7 @@ }; }; + // Mock `gl` object in window for project specific page. App code will need it. mockProjectOptions = function() { window.gl || (window.gl = {}); return window.gl.projectOptions = { @@ -105,20 +101,20 @@ a3 = "a[href='" + mrsAssignedToMeLink + "']"; a4 = "a[href='" + mrsIHaveCreatedLink + "']"; expect(list.find(a1).length).toBe(1); - expect(list.find(a1).text()).toBe(' Issues assigned to me '); + expect(list.find(a1).text()).toBe('Issues assigned to me'); expect(list.find(a2).length).toBe(1); - expect(list.find(a2).text()).toBe(" Issues I've created "); + expect(list.find(a2).text()).toBe("Issues I've created"); expect(list.find(a3).length).toBe(1); - expect(list.find(a3).text()).toBe(' Merge requests assigned to me '); + expect(list.find(a3).text()).toBe('Merge requests assigned to me'); expect(list.find(a4).length).toBe(1); - return expect(list.find(a4).text()).toBe(" Merge requests I've created "); + return expect(list.find(a4).text()).toBe("Merge requests I've created"); }; describe('Search autocomplete dropdown', function() { fixture.preload('search_autocomplete.html'); beforeEach(function() { fixture.load('search_autocomplete.html'); - return widget = new SearchAutocomplete; + return widget = new gl.SearchAutocomplete; }); it('should show Dashboard specific dropdown menu', function() { var list; @@ -144,7 +140,7 @@ list = widget.wrap.find('.dropdown-menu').find('ul'); return assertLinks(list, projectIssuesPath, projectMRsPath); }); - return it('should not show category related menu if there is text in the input', function() { + it('should not show category related menu if there is text in the input', function() { var link, list; addBodyAttributes('project'); mockProjectOptions(); @@ -154,6 +150,23 @@ link = "a[href='" + projectIssuesPath + "/?assignee_id=" + userId + "']"; return expect(list.find(link).length).toBe(0); }); + return it('should not submit the search form when selecting an autocomplete row with the keyboard', function() { + var ENTER = 13; + var DOWN = 40; + addBodyAttributes(); + mockDashboardOptions(true); + var submitSpy = spyOnEvent('form', 'submit'); + widget.searchInput.focus(); + widget.wrap.trigger($.Event('keydown', { which: DOWN })); + var enterKeyEvent = $.Event('keydown', { which: ENTER }); + widget.searchInput.trigger(enterKeyEvent); + // This does not currently catch failing behavior. For security reasons, + // browsers will not trigger default behavior (form submit, in this + // example) on JavaScript-created keypresses. + expect(submitSpy).not.toHaveBeenTriggered(); + // Does a worse job at capturing the intent of the test, but works. + expect(enterKeyEvent.isDefaultPrevented()).toBe(true); + }); }); }).call(this); diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index 7b6b55fe545..04ccf246052 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -10,6 +10,7 @@ }); return describe('#replyWithSelectedText', function() { var stubSelection; + // Stub window.getSelection to return the provided String. stubSelection = function(text) { return window.getSelection = function() { return text; diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index 7d91ed0f855..8801c297887 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -1,21 +1,41 @@ - +// PhantomJS (Teaspoons default driver) doesn't have support for +// Function.prototype.bind, which has caused confusion. Use this polyfill to +// avoid the confusion. /*= require support/bind-poly */ - +// You can require your own javascript files here. By default this will include +// everything in application, however you may get better load performance if you +// require the specific files that are being used in the spec that tests them. /*= require jquery */ - - /*= require jquery.turbolinks */ - - /*= require bootstrap */ - - /*= require underscore */ - +// Teaspoon includes some support files, but you can use anything from your own +// support path too. +// require support/jasmine-jquery-1.7.0 +// require support/jasmine-jquery-2.0.0 /*= require support/jasmine-jquery-2.1.0 */ +// require support/sinon +// require support/your-support-file +// Deferring execution +// If you're using CommonJS, RequireJS or some other asynchronous library you can +// defer execution. Call Teaspoon.execute() after everything has been loaded. +// Simple example of a timeout: +// Teaspoon.defer = true +// setTimeout(Teaspoon.execute, 1000) +// Matching files +// By default Teaspoon will look for files that match +// _spec.{js,js.coffee,.coffee}. Add a filename_spec.js file in your spec path +// and it'll be included in the default suite automatically. If you want to +// customize suites, check out the configuration in teaspoon_env.rb +// Manifest +// If you'd rather require your spec files manually (to control order for +// instance) you can disable the suite matcher in the configuration and use this +// file as a manifest. +// For more information: http://github.com/modeset/teaspoon + (function() { diff --git a/spec/javascripts/u2f/authenticate_spec.js b/spec/javascripts/u2f/authenticate_spec.js index e008ce956ad..7ce3884f844 100644 --- a/spec/javascripts/u2f/authenticate_spec.js +++ b/spec/javascripts/u2f/authenticate_spec.js @@ -1,16 +1,8 @@ /*= require u2f/authenticate */ - - /*= require u2f/util */ - - /*= require u2f/error */ - - /*= require u2f */ - - /*= require ./mock_u2f_device */ (function() { diff --git a/spec/javascripts/u2f/register_spec.js b/spec/javascripts/u2f/register_spec.js index 21c5266c60e..01d6b7a8961 100644 --- a/spec/javascripts/u2f/register_spec.js +++ b/spec/javascripts/u2f/register_spec.js @@ -1,16 +1,8 @@ /*= require u2f/register */ - - /*= require u2f/util */ - - /*= require u2f/error */ - - /*= require u2f */ - - /*= require ./mock_u2f_device */ (function() { diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js index 3d680ec8ea3..0c1266800d7 100644 --- a/spec/javascripts/zen_mode_spec.js +++ b/spec/javascripts/zen_mode_spec.js @@ -14,8 +14,10 @@ return true; } }; + // Stub Dropzone.forElement(...).enable() }); this.zen = new ZenMode(); + // Set this manually because we can't actually scroll the window return this.zen.scroll_position = 456; }); describe('on enter', function() { @@ -60,7 +62,7 @@ return $('a.js-zen-enter').click(); }; - exitZen = function() { + exitZen = function() { // Ohmmmmmmm return $('a.js-zen-leave').click(); }; diff --git a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb index 593bd6d5cac..e6c90ad87ee 100644 --- a/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/commit_range_reference_filter_spec.rb @@ -65,14 +65,14 @@ describe Banzai::Filter::CommitRangeReferenceFilter, lib: true do expect(reference_filter(act).to_html).to eq exp end - it 'includes a title attribute' do + it 'includes no title attribute' do doc = reference_filter("See #{reference}") - expect(doc.css('a').first.attr('title')).to eq range.reference_title + expect(doc.css('a').first.attr('title')).to eq "" end it 'includes default classes' do doc = reference_filter("See #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit_range' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit_range has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/commit_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_reference_filter_spec.rb index d46d3f1489e..e0f08282551 100644 --- a/spec/lib/banzai/filter/commit_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/commit_reference_filter_spec.rb @@ -55,7 +55,7 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do it 'includes a title attribute' do doc = reference_filter("See #{reference}") - expect(doc.css('a').first.attr('title')).to eq commit.link_title + expect(doc.css('a').first.attr('title')).to eq commit.title end it 'escapes the title attribute' do @@ -67,7 +67,7 @@ describe Banzai::Filter::CommitReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("See #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb index b5b38cf0c8c..c8e62f528df 100644 --- a/spec/lib/banzai/filter/emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/emoji_filter_spec.rb @@ -12,11 +12,16 @@ describe Banzai::Filter::EmojiFilter, lib: true do ActionController::Base.asset_host = @original_asset_host end - it 'replaces supported emoji' do + it 'replaces supported name emoji' do doc = filter('<p>:heart:</p>') expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/2764.png' end + it 'replaces supported unicode emoji' do + doc = filter('<p>❤️</p>') + expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/2764.png' + end + it 'ignores unsupported emoji' do exp = act = '<p>:foo:</p>' doc = filter(act) @@ -28,46 +33,96 @@ describe Banzai::Filter::EmojiFilter, lib: true do expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/1F44D.png' end + it 'correctly encodes unicode to the URL' do + doc = filter('<p>👍</p>') + expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/1F44D.png' + end + it 'matches at the start of a string' do doc = filter(':+1:') expect(doc.css('img').size).to eq 1 end + it 'unicode matches at the start of a string' do + doc = filter("'👍'") + expect(doc.css('img').size).to eq 1 + end + it 'matches at the end of a string' do doc = filter('This gets a :-1:') expect(doc.css('img').size).to eq 1 end + it 'unicode matches at the end of a string' do + doc = filter('This gets a 👍') + expect(doc.css('img').size).to eq 1 + end + it 'matches with adjacent text' do doc = filter('+1 (:+1:)') expect(doc.css('img').size).to eq 1 end + it 'unicode matches with adjacent text' do + doc = filter('+1 (👍)') + expect(doc.css('img').size).to eq 1 + end + it 'matches multiple emoji in a row' do doc = filter(':see_no_evil::hear_no_evil::speak_no_evil:') expect(doc.css('img').size).to eq 3 end + it 'unicode matches multiple emoji in a row' do + doc = filter("'🙈🙉🙊'") + expect(doc.css('img').size).to eq 3 + end + + it 'mixed matches multiple emoji in a row' do + doc = filter("'🙈:see_no_evil:🙉:hear_no_evil:🙊:speak_no_evil:'") + expect(doc.css('img').size).to eq 6 + end + it 'has a title attribute' do doc = filter(':-1:') expect(doc.css('img').first.attr('title')).to eq ':-1:' end + it 'unicode has a title attribute' do + doc = filter("'👎'") + expect(doc.css('img').first.attr('title')).to eq ':thumbsdown:' + end + it 'has an alt attribute' do doc = filter(':-1:') expect(doc.css('img').first.attr('alt')).to eq ':-1:' end + it 'unicode has an alt attribute' do + doc = filter("'👎'") + expect(doc.css('img').first.attr('alt')).to eq ':thumbsdown:' + end + it 'has an align attribute' do doc = filter(':8ball:') expect(doc.css('img').first.attr('align')).to eq 'absmiddle' end + it 'unicode has an align attribute' do + doc = filter("'🎱'") + expect(doc.css('img').first.attr('align')).to eq 'absmiddle' + end + it 'has an emoji class' do doc = filter(':cat:') expect(doc.css('img').first.attr('class')).to eq 'emoji' end + it 'unicode has an emoji class' do + doc = filter("'🐱'") + expect(doc.css('img').first.attr('class')).to eq 'emoji' + end + it 'has height and width attributes' do doc = filter(':dog:') img = doc.css('img').first @@ -76,12 +131,26 @@ describe Banzai::Filter::EmojiFilter, lib: true do expect(img.attr('height')).to eq '20' end + it 'unicode has height and width attributes' do + doc = filter("'🐶'") + img = doc.css('img').first + + expect(img.attr('width')).to eq '20' + expect(img.attr('height')).to eq '20' + end + it 'keeps whitespace intact' do doc = filter('This deserves a :+1:, big time.') expect(doc.to_html).to match(/^This deserves a <img.+>, big time\.\z/) end + it 'unicode keeps whitespace intact' do + doc = filter('This deserves a 🎱, big time.') + + expect(doc.to_html).to match(/^This deserves a <img.+>, big time\.\z/) + end + it 'uses a custom asset_root context' do root = Gitlab.config.gitlab.url + 'gitlab/root' @@ -95,4 +164,18 @@ describe Banzai::Filter::EmojiFilter, lib: true do doc = filter(':frowning:', asset_host: 'https://this-is-ignored-i-guess?') expect(doc.css('img').first.attr('src')).to start_with('https://cdn.example.com') end + + it 'uses a custom asset_root context' do + root = Gitlab.config.gitlab.url + 'gitlab/root' + + doc = filter("'🎱'", asset_root: root) + expect(doc.css('img').first.attr('src')).to start_with(root) + end + + it 'uses a custom asset_host context' do + ActionController::Base.asset_host = 'https://cdn.example.com' + + doc = filter("'🎱'", asset_host: 'https://this-is-ignored-i-guess?') + expect(doc.css('img').first.attr('src')).to start_with('https://cdn.example.com') + end end diff --git a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb index 953466679e4..7116c09fb21 100644 --- a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb @@ -64,7 +64,7 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do it 'includes default classes' do doc = filter("Issue #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip' end it 'supports an :only_path context' do diff --git a/spec/lib/banzai/filter/html_entity_filter_spec.rb b/spec/lib/banzai/filter/html_entity_filter_spec.rb new file mode 100644 index 00000000000..4c68ce6d6e4 --- /dev/null +++ b/spec/lib/banzai/filter/html_entity_filter_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe Banzai::Filter::HtmlEntityFilter, lib: true do + include FilterSpecHelper + + let(:unescaped) { 'foo <strike attr="foo">&&&</strike>' } + let(:escaped) { 'foo <strike attr="foo">&&&</strike>' } + + it 'converts common entities to their HTML-escaped equivalents' do + output = filter(unescaped) + + expect(output).to eq(escaped) + end +end diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb index a005b4990e7..fce86a9b6ad 100644 --- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb @@ -54,7 +54,7 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do it 'includes a title attribute' do doc = reference_filter("Issue #{reference}") - expect(doc.css('a').first.attr('title')).to eq "Issue: #{issue.title}" + expect(doc.css('a').first.attr('title')).to eq issue.title end it 'escapes the title attribute' do @@ -66,7 +66,7 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("Issue #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb index 9276a154007..908ccebbf87 100644 --- a/spec/lib/banzai/filter/label_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb @@ -21,7 +21,7 @@ describe Banzai::Filter::LabelReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("Label #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb index 805acf1c8b3..274258a045c 100644 --- a/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/merge_request_reference_filter_spec.rb @@ -46,7 +46,7 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do it 'includes a title attribute' do doc = reference_filter("Merge #{reference}") - expect(doc.css('a').first.attr('title')).to eq "Merge Request: #{merge.title}" + expect(doc.css('a').first.attr('title')).to eq merge.title end it 'escapes the title attribute' do @@ -58,7 +58,7 @@ describe Banzai::Filter::MergeRequestReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("Merge #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-merge_request' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-merge_request has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb index 9424f2363e1..7419863d848 100644 --- a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb @@ -20,7 +20,7 @@ describe Banzai::Filter::MilestoneReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("Milestone #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-milestone' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-milestone has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb index 224baca8030..6b58f3e43ee 100644 --- a/spec/lib/banzai/filter/relative_link_filter_spec.rb +++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Banzai::Filter::RelativeLinkFilter, lib: true do def filter(doc, contexts = {}) contexts.reverse_merge!({ - commit: project.commit, + commit: commit, project: project, project_wiki: project_wiki, ref: ref, @@ -28,6 +28,7 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do let(:project) { create(:project) } let(:project_path) { project.path_with_namespace } let(:ref) { 'markdown' } + let(:commit) { project.commit(ref) } let(:project_wiki) { nil } let(:requested_path) { '/' } @@ -77,13 +78,24 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do expect { filter(act) }.not_to raise_error end - context 'with a valid repository' do + it 'ignores ref if commit is passed' do + doc = filter(link('non/existent.file'), commit: project.commit('empty-branch') ) + expect(doc.at_css('a')['href']). + to eq "/#{project_path}/#{ref}/non/existent.file" # non-existent files have no leading blob/raw/tree + end + + shared_examples :valid_repository do it 'rebuilds absolute URL for a file in the repo' do doc = filter(link('/doc/api/README.md')) expect(doc.at_css('a')['href']). to eq "/#{project_path}/blob/#{ref}/doc/api/README.md" end + it 'ignores absolute URLs with two leading slashes' do + doc = filter(link('//doc/api/README.md')) + expect(doc.at_css('a')['href']).to eq '//doc/api/README.md' + end + it 'rebuilds relative URL for a file in the repo' do doc = filter(link('doc/api/README.md')) expect(doc.at_css('a')['href']). @@ -184,4 +196,13 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do include_examples :relative_to_requested end end + + context 'with a valid commit' do + include_examples :valid_repository + end + + context 'with a valid ref' do + let(:commit) { nil } # force filter to use ref instead of commit + include_examples :valid_repository + end end diff --git a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb index 5068ddd7faa..9b92d1a3926 100644 --- a/spec/lib/banzai/filter/snippet_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/snippet_reference_filter_spec.rb @@ -39,7 +39,7 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do it 'includes a title attribute' do doc = reference_filter("Snippet #{reference}") - expect(doc.css('a').first.attr('title')).to eq "Snippet: #{snippet.title}" + expect(doc.css('a').first.attr('title')).to eq snippet.title end it 'escapes the title attribute' do @@ -51,7 +51,7 @@ describe Banzai::Filter::SnippetReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("Snippet #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-snippet' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-snippet has-tooltip' end it 'includes a data-project attribute' do diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb index b1370bca833..d265d29ee86 100644 --- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb +++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb @@ -6,21 +6,21 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do context "when no language is specified" do it "highlights as plaintext" do result = filter('<pre><code>def fun end</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext"><code>def fun end</code></pre>') + expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>def fun end</code></pre>') end end context "when a valid language is specified" do it "highlights as that language" do result = filter('<pre><code class="ruby">def fun end</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>') + expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" v-pre="true"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>') end end context "when an invalid language is specified" do it "highlights as plaintext" do result = filter('<pre><code class="gnuplot">This is a test</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext"><code>This is a test</code></pre>') + expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>This is a test</code></pre>') end end @@ -31,7 +31,7 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do it "highlights as plaintext" do result = filter('<pre><code class="ruby">This is a test</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight"><code>This is a test</code></pre>') + expect(result.to_html).to eq('<pre class="code highlight" v-pre="true"><code>This is a test</code></pre>') end end end diff --git a/spec/lib/banzai/filter/task_list_filter_spec.rb b/spec/lib/banzai/filter/task_list_filter_spec.rb deleted file mode 100644 index 569cbc885c7..00000000000 --- a/spec/lib/banzai/filter/task_list_filter_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'spec_helper' - -describe Banzai::Filter::TaskListFilter, lib: true do - include FilterSpecHelper - - it 'does not apply `task-list` class to non-task lists' do - exp = act = %(<ul><li>Item</li></ul>) - expect(filter(act).to_html).to eq exp - end - - it 'applies `task-list` to single-item task lists' do - act = filter('<ul><li>[ ] Task 1</li></ul>') - - expect(act.to_html).to start_with '<ul class="task-list">' - end -end diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index 108b36a97cc..729e77fd43f 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -31,13 +31,16 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do end it 'supports a special @all mention' do + project.team << [user, :developer] doc = reference_filter("Hey #{reference}", author: user) + expect(doc.css('a').length).to eq 1 expect(doc.css('a').first.attr('href')) .to eq urls.namespace_project_url(project.namespace, project) end it 'includes a data-author attribute when there is an author' do + project.team << [user, :developer] doc = reference_filter(reference, author: user) expect(doc.css('a').first.attr('data-author')).to eq(user.id.to_s) @@ -48,6 +51,12 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do expect(doc.css('a').first.has_attribute?('data-author')).to eq(false) end + + it 'ignores reference to all when the user is not a project member' do + doc = reference_filter("Hey #{reference}", author: user) + + expect(doc.css('a').length).to eq 0 + end end context 'mentioning a user' do @@ -104,7 +113,7 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do it 'includes default classes' do doc = reference_filter("Hey #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member has-tooltip' end it 'supports an :only_path context' do diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb index cc4349f80ba..6ab1be9ccb7 100644 --- a/spec/lib/banzai/filter/video_link_filter_spec.rb +++ b/spec/lib/banzai/filter/video_link_filter_spec.rb @@ -47,5 +47,4 @@ describe Banzai::Filter::VideoLinkFilter, lib: true do expect(element['src']).to eq '/path/my_image.jpg' end end - end diff --git a/spec/lib/banzai/note_renderer_spec.rb b/spec/lib/banzai/note_renderer_spec.rb index 98f76f36fd5..49556074278 100644 --- a/spec/lib/banzai/note_renderer_spec.rb +++ b/spec/lib/banzai/note_renderer_spec.rb @@ -12,8 +12,7 @@ describe Banzai::NoteRenderer do with(project, user, requested_path: 'foo', project_wiki: wiki, - ref: 'bar', - pipeline: :note). + ref: 'bar'). and_call_original expect_any_instance_of(Banzai::ObjectRenderer). diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb index bcdb95250ca..90da78a67dd 100644 --- a/spec/lib/banzai/object_renderer_spec.rb +++ b/spec/lib/banzai/object_renderer_spec.rb @@ -4,10 +4,18 @@ describe Banzai::ObjectRenderer do let(:project) { create(:empty_project) } let(:user) { project.owner } + def fake_object(attrs = {}) + object = double(attrs.merge("new_record?" => true, "destroyed?" => true)) + allow(object).to receive(:markdown_cache_field_for).with(:note).and_return(:note_html) + allow(object).to receive(:banzai_render_context).with(:note).and_return(project: nil, author: nil) + allow(object).to receive(:update_column).with(:note_html, anything).and_return(true) + object + end + describe '#render' do it 'renders and redacts an Array of objects' do renderer = described_class.new(project, user) - object = double(:object, note: 'hello', note_html: nil) + object = fake_object(note: 'hello', note_html: nil) expect(renderer).to receive(:render_objects).with([object], :note). and_call_original @@ -16,7 +24,7 @@ describe Banzai::ObjectRenderer do with(an_instance_of(Array)). and_call_original - expect(object).to receive(:note_html=).with('<p>hello</p>') + expect(object).to receive(:redacted_note_html=).with('<p>hello</p>') expect(object).to receive(:user_visible_reference_count=).with(0) renderer.render([object], :note) @@ -25,7 +33,7 @@ describe Banzai::ObjectRenderer do describe '#render_objects' do it 'renders an Array of objects' do - object = double(:object, note: 'hello') + object = fake_object(note: 'hello', note_html: nil) renderer = described_class.new(project, user) @@ -57,49 +65,29 @@ describe Banzai::ObjectRenderer do end describe '#context_for' do - let(:object) { double(:object, note: 'hello') } + let(:object) { fake_object(note: 'hello') } let(:renderer) { described_class.new(project, user) } it 'returns a Hash' do expect(renderer.context_for(object, :note)).to be_an_instance_of(Hash) end - it 'includes the cache key' do + it 'includes the banzai render context for the object' do + expect(object).to receive(:banzai_render_context).with(:note).and_return(foo: :bar) context = renderer.context_for(object, :note) - - expect(context[:cache_key]).to eq([object, :note]) - end - - context 'when the object responds to "author"' do - it 'includes the author in the context' do - expect(object).to receive(:author).and_return('Alice') - - context = renderer.context_for(object, :note) - - expect(context[:author]).to eq('Alice') - end - end - - context 'when the object does not respond to "author"' do - it 'does not include the author in the context' do - context = renderer.context_for(object, :note) - - expect(context.key?(:author)).to eq(false) - end + expect(context).to have_key(:foo) + expect(context[:foo]).to eq(:bar) end end describe '#render_attributes' do it 'renders the attribute of a list of objects' do - objects = [double(:doc, note: 'hello'), double(:doc, note: 'bye')] - renderer = described_class.new(project, user, pipeline: :note) + objects = [fake_object(note: 'hello', note_html: nil), fake_object(note: 'bye', note_html: nil)] + renderer = described_class.new(project, user) - expect(Banzai).to receive(:cache_collection_render). - with([ - { text: 'hello', context: renderer.context_for(objects[0], :note) }, - { text: 'bye', context: renderer.context_for(objects[1], :note) } - ]). - and_call_original + objects.each do |object| + expect(Banzai).to receive(:render_field).with(object, :note).and_call_original + end docs = renderer.render_attributes(objects, :note) @@ -114,17 +102,13 @@ describe Banzai::ObjectRenderer do objects = [] renderer = described_class.new(project, user, pipeline: :note) - expect(Banzai).to receive(:cache_collection_render). - with([]). - and_call_original - expect(renderer.render_attributes(objects, :note)).to eq([]) end end describe '#base_context' do let(:context) do - described_class.new(project, user, pipeline: :note).base_context + described_class.new(project, user, foo: :bar).base_context end it 'returns a Hash' do @@ -132,7 +116,7 @@ describe Banzai::ObjectRenderer do end it 'includes the custom attributes' do - expect(context[:pipeline]).to eq(:note) + expect(context[:foo]).to eq(:bar) end it 'includes the current user' do diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb index 51c89ac4889..ac9bde6baf1 100644 --- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb @@ -127,6 +127,13 @@ describe Banzai::Pipeline::WikiPipeline do expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/wikis/nested/twice/page.md\"") end + + it 'rewrites links with anchor' do + markdown = '[Link to Header](start-page#title)' + output = described_class.to_html(markdown, project: project, project_wiki: project_wiki, page_slug: page.slug) + + expect(output).to include("href=\"#{relative_url_root}/wiki_link_ns/wiki_link_project/wikis/start-page#title\"") + end end describe "when creating root links" do diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb index ac9c66e2663..9095d2b1345 100644 --- a/spec/lib/banzai/reference_parser/base_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb @@ -30,7 +30,7 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do it 'returns the nodes if the attribute value equals the current project ID' do link['data-project'] = project.id.to_s - expect(Ability.abilities).not_to receive(:allowed?) + expect(Ability).not_to receive(:allowed?) expect(subject.nodes_visible_to_user(user, [link])).to eq([link]) end @@ -39,7 +39,7 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do link['data-project'] = other_project.id.to_s - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_project, other_project). and_return(true) @@ -57,7 +57,7 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do link['data-project'] = other_project.id.to_s - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_project, other_project). and_return(false) @@ -221,7 +221,7 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do it 'delegates the permissions check to the Ability class' do user = double(:user) - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_project, project) subject.can?(user, :read_project, project) diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb index 9a82891297d..4e7f82a6e09 100644 --- a/spec/lib/banzai/reference_parser/user_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb @@ -82,7 +82,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do end it 'returns the nodes if the user can read the group' do - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_group, group). and_return(true) @@ -90,7 +90,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do end it 'returns an empty Array if the user can not read the group' do - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_group, group). and_return(false) @@ -103,7 +103,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do it 'returns the nodes if the attribute value equals the current project ID' do link['data-project'] = project.id.to_s - expect(Ability.abilities).not_to receive(:allowed?) + expect(Ability).not_to receive(:allowed?) expect(subject.nodes_visible_to_user(user, [link])).to eq([link]) end @@ -113,7 +113,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do link['data-project'] = other_project.id.to_s - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_project, other_project). and_return(true) @@ -125,7 +125,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do link['data-project'] = other_project.id.to_s - expect(Ability.abilities).to receive(:allowed?). + expect(Ability).to receive(:allowed?). with(user, :read_project, other_project). and_return(false) diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb new file mode 100644 index 00000000000..aaa6b12e67e --- /dev/null +++ b/spec/lib/banzai/renderer_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe Banzai::Renderer do + def expect_render(project = :project) + expected_context = { project: project } + expect(renderer).to receive(:cacheless_render) { :html }.with(:markdown, expected_context) + end + + def expect_cache_update + expect(object).to receive(:update_column).with("field_html", :html) + end + + def fake_object(*features) + markdown = :markdown if features.include?(:markdown) + html = :html if features.include?(:html) + + object = double( + "object", + banzai_render_context: { project: :project }, + field: markdown, + field_html: html + ) + + allow(object).to receive(:markdown_cache_field_for).with(:field).and_return("field_html") + allow(object).to receive(:new_record?).and_return(features.include?(:new)) + allow(object).to receive(:destroyed?).and_return(features.include?(:destroyed)) + + object + end + + describe "#render_field" do + let(:renderer) { Banzai::Renderer } + let(:subject) { renderer.render_field(object, :field) } + + context "with an empty cache" do + let(:object) { fake_object(:markdown) } + it "caches and returns the result" do + expect_render + expect_cache_update + expect(subject).to eq(:html) + end + end + + context "with a filled cache" do + let(:object) { fake_object(:markdown, :html) } + + it "uses the cache" do + expect_render.never + expect_cache_update.never + should eq(:html) + end + end + + context "new object" do + let(:object) { fake_object(:new, :markdown) } + + it "doesn't cache the result" do + expect_render + expect_cache_update.never + expect(subject).to eq(:html) + end + end + + context "destroyed object" do + let(:object) { fake_object(:destroyed, :markdown) } + + it "doesn't cache the result" do + expect_render + expect_cache_update.never + expect(subject).to eq(:html) + end + end + end +end diff --git a/spec/lib/ci/charts_spec.rb b/spec/lib/ci/charts_spec.rb index 97f2e97b062..fb6cc398307 100644 --- a/spec/lib/ci/charts_spec.rb +++ b/spec/lib/ci/charts_spec.rb @@ -2,21 +2,23 @@ require 'spec_helper' describe Ci::Charts, lib: true do context "build_times" do + let(:project) { create(:empty_project) } + let(:chart) { Ci::Charts::BuildTime.new(project) } + + subject { chart.build_times } + before do - @pipeline = FactoryGirl.create(:ci_pipeline) - FactoryGirl.create(:ci_build, pipeline: @pipeline) + create(:ci_empty_pipeline, project: project, duration: 120) end - it 'should return build times in minutes' do - chart = Ci::Charts::BuildTime.new(@pipeline.project) - expect(chart.build_times).to eq([2]) + it 'returns build times in minutes' do + is_expected.to contain_exactly(2) end - it 'should handle nil build times' do - create(:ci_pipeline, duration: nil, project: @pipeline.project) + it 'handles nil build times' do + create(:ci_empty_pipeline, project: project, duration: nil) - chart = Ci::Charts::BuildTime.new(@pipeline.project) - expect(chart.build_times).to eq([2, 0]) + is_expected.to contain_exactly(2, 0) end end end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 61490555ff5..6dedd25e9d3 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -19,7 +19,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref(type, "master").first).to eq({ stage: "test", stage_idx: 1, - name: :rspec, + name: "rspec", commands: "pwd\nrspec", tag_list: [], options: {}, @@ -433,7 +433,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({ stage: "test", stage_idx: 1, - name: :rspec, + name: "rspec", commands: "pwd\nrspec", tag_list: [], options: { @@ -461,7 +461,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({ stage: "test", stage_idx: 1, - name: :rspec, + name: "rspec", commands: "pwd\nrspec", tag_list: [], options: { @@ -533,10 +533,6 @@ module Ci } end - context 'when also global variables are defined' do - - end - context 'when syntax is correct' do let(:variables) do { VAR1: 'value1', VAR2: 'value2' } @@ -704,7 +700,7 @@ module Ci expect(config_processor.builds_for_stage_and_ref("test", "master").first).to eq({ stage: "test", stage_idx: 1, - name: :rspec, + name: "rspec", commands: "pwd\nrspec", tag_list: [], options: { @@ -758,6 +754,20 @@ module Ci it 'does return production' do expect(builds.size).to eq(1) expect(builds.first[:environment]).to eq(environment) + expect(builds.first[:options]).to include(environment: { name: environment }) + end + end + + context 'when hash is specified' do + let(:environment) do + { name: 'production', + url: 'http://production.gitlab.com' } + end + + it 'does return production and URL' 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 @@ -774,15 +784,16 @@ module Ci let(:environment) { 1 } it 'raises error' do - expect { builds }.to raise_error("jobs:deploy_to_production environment #{Gitlab::Regex.environment_name_regex_message}") + expect { builds }.to raise_error( + 'jobs:deploy_to_production:environment config should be a hash or a string') end end context 'is not a valid string' do - let(:environment) { 'production staging' } + let(:environment) { 'production:staging' } it 'raises error' do - expect { builds }.to raise_error("jobs:deploy_to_production environment #{Gitlab::Regex.environment_name_regex_message}") + expect { builds }.to raise_error("jobs:deploy_to_production:environment name #{Gitlab::Regex.environment_name_regex_message}") end end end @@ -841,7 +852,7 @@ module Ci expect(subject.first).to eq({ stage: "test", stage_idx: 1, - name: :normal_job, + name: "normal_job", commands: "test", tag_list: [], options: {}, @@ -886,7 +897,7 @@ module Ci expect(subject.first).to eq({ stage: "build", stage_idx: 0, - name: :job1, + name: "job1", commands: "execute-script-for-job", tag_list: [], options: {}, @@ -898,7 +909,7 @@ module Ci expect(subject.second).to eq({ stage: "build", stage_idx: 0, - name: :job2, + name: "job2", commands: "execute-script-for-job", tag_list: [], options: {}, @@ -1254,5 +1265,40 @@ EOT end end end + + describe "#validation_message" do + context "when the YAML could not be parsed" do + it "returns an error about invalid configutaion" do + content = YAML.dump("invalid: yaml: test") + + expect(GitlabCiYamlProcessor.validation_message(content)) + .to eq "Invalid configuration format" + end + end + + context "when the tags parameter is invalid" do + it "returns an error about invalid tags" do + content = YAML.dump({ rspec: { script: "test", tags: "mysql" } }) + + expect(GitlabCiYamlProcessor.validation_message(content)) + .to eq "jobs:rspec tags should be an array of strings" + end + end + + context "when YAML content is empty" do + it "returns an error about missing content" do + expect(GitlabCiYamlProcessor.validation_message('')) + .to eq "Please provide content of .gitlab-ci.yml" + end + end + + context "when the YAML is valid" do + it "does not return any errors" do + content = File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + + expect(GitlabCiYamlProcessor.validation_message(content)).to be_nil + end + end + end end end diff --git a/spec/lib/ci/mask_secret_spec.rb b/spec/lib/ci/mask_secret_spec.rb new file mode 100644 index 00000000000..3101bed20fb --- /dev/null +++ b/spec/lib/ci/mask_secret_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Ci::MaskSecret, lib: true do + subject { described_class } + + describe '#mask' do + it 'masks exact number of characters' do + expect(mask('token', 'oke')).to eq('txxxn') + end + + it 'masks multiple occurrences' do + expect(mask('token token token', 'oke')).to eq('txxxn txxxn txxxn') + end + + it 'does not mask if not found' do + expect(mask('token', 'not')).to eq('token') + end + + it 'does support null token' do + expect(mask('token', nil)).to eq('token') + end + + def mask(value, token) + subject.mask!(value.dup, token) + end + end +end diff --git a/spec/lib/constraints/group_url_constrainer_spec.rb b/spec/lib/constraints/group_url_constrainer_spec.rb new file mode 100644 index 00000000000..f0b75a664f2 --- /dev/null +++ b/spec/lib/constraints/group_url_constrainer_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' + +describe GroupUrlConstrainer, lib: true do + let!(:username) { create(:group, path: 'gitlab-org') } + + describe '#find_resource' do + it { expect(!!subject.find_resource('gitlab-org')).to be_truthy } + it { expect(!!subject.find_resource('gitlab-com')).to be_falsey } + end +end diff --git a/spec/lib/constraints/namespace_url_constrainer_spec.rb b/spec/lib/constraints/namespace_url_constrainer_spec.rb new file mode 100644 index 00000000000..a5feaacb8ee --- /dev/null +++ b/spec/lib/constraints/namespace_url_constrainer_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe NamespaceUrlConstrainer, lib: true do + let!(:group) { create(:group, path: 'gitlab') } + + describe '#matches?' do + context 'existing namespace' do + it { expect(subject.matches?(request '/gitlab')).to be_truthy } + it { expect(subject.matches?(request '/gitlab.atom')).to be_truthy } + it { expect(subject.matches?(request '/gitlab/')).to be_truthy } + it { expect(subject.matches?(request '//gitlab/')).to be_truthy } + end + + context 'non-existing namespace' do + it { expect(subject.matches?(request '/gitlab-ce')).to be_falsey } + it { expect(subject.matches?(request '/gitlab.ce')).to be_falsey } + it { expect(subject.matches?(request '/g/gitlab')).to be_falsey } + it { expect(subject.matches?(request '/.gitlab')).to be_falsey } + end + end + + def request(path) + OpenStruct.new(path: path) + end +end diff --git a/spec/lib/constraints/user_url_constrainer_spec.rb b/spec/lib/constraints/user_url_constrainer_spec.rb new file mode 100644 index 00000000000..4b26692672f --- /dev/null +++ b/spec/lib/constraints/user_url_constrainer_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' + +describe UserUrlConstrainer, lib: true do + let!(:username) { create(:user, username: 'dz') } + + describe '#find_resource' do + it { expect(!!subject.find_resource('dz')).to be_truthy } + it { expect(!!subject.find_resource('john')).to be_falsey } + end +end diff --git a/spec/lib/disable_email_interceptor_spec.rb b/spec/lib/disable_email_interceptor_spec.rb index 309a88151cf..8f51474476d 100644 --- a/spec/lib/disable_email_interceptor_spec.rb +++ b/spec/lib/disable_email_interceptor_spec.rb @@ -5,7 +5,7 @@ describe DisableEmailInterceptor, lib: true do Mail.register_interceptor(DisableEmailInterceptor) end - it 'should not send emails' do + it 'does not send emails' do allow(Gitlab.config.gitlab).to receive(:email_enabled).and_return(false) expect { deliver_mail }.not_to change(ActionMailer::Base.deliveries, :count) end diff --git a/spec/lib/event_filter_spec.rb b/spec/lib/event_filter_spec.rb new file mode 100644 index 00000000000..a6d8e6927e0 --- /dev/null +++ b/spec/lib/event_filter_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe EventFilter, lib: true do + describe '#apply_filter' do + let(:source_user) { create(:user) } + let!(:public_project) { create(:project, :public) } + + let!(:push_event) { create(:event, action: Event::PUSHED, project: public_project, target: public_project, author: source_user) } + let!(:merged_event) { create(:event, action: Event::MERGED, project: public_project, target: public_project, author: source_user) } + let!(:comments_event) { create(:event, action: Event::COMMENTED, project: public_project, target: public_project, author: source_user) } + let!(:joined_event) { create(:event, action: Event::JOINED, project: public_project, target: public_project, author: source_user) } + let!(:left_event) { create(:event, action: Event::LEFT, project: public_project, target: public_project, author: source_user) } + + it 'applies push filter' do + events = EventFilter.new(EventFilter.push).apply_filter(Event.all) + expect(events).to contain_exactly(push_event) + end + + it 'applies merged filter' do + events = EventFilter.new(EventFilter.merged).apply_filter(Event.all) + expect(events).to contain_exactly(merged_event) + end + + it 'applies comments filter' do + events = EventFilter.new(EventFilter.comments).apply_filter(Event.all) + expect(events).to contain_exactly(comments_event) + end + + it 'applies team filter' do + events = EventFilter.new(EventFilter.team).apply_filter(Event.all) + expect(events).to contain_exactly(joined_event, left_event) + end + + it 'applies all filter' do + events = EventFilter.new(EventFilter.all).apply_filter(Event.all) + expect(events).to contain_exactly(push_event, merged_event, comments_event, joined_event, left_event) + end + + it 'applies no filter' do + events = EventFilter.new(nil).apply_filter(Event.all) + expect(events).to contain_exactly(push_event, merged_event, comments_event, joined_event, left_event) + end + + it 'applies unknown filter' do + events = EventFilter.new('').apply_filter(Event.all) + expect(events).to contain_exactly(push_event, merged_event, comments_event, joined_event, left_event) + end + end +end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb new file mode 100644 index 00000000000..90bc7dad379 --- /dev/null +++ b/spec/lib/expand_variables_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe ExpandVariables do + describe '#expand' do + subject { described_class.expand(value, variables) } + + tests = [ + { value: 'key', + result: 'key', + variables: [] + }, + { value: 'key$variable', + result: 'key', + variables: [] + }, + { value: 'key$variable', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value' } + ] + }, + { value: 'key${variable}', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value' } + ] + }, + { value: 'key$variable$variable2', + result: 'keyvalueresult', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + ] + }, + { value: 'key${variable}${variable2}', + result: 'keyvalueresult', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ] + }, + { value: 'key$variable2$variable', + result: 'keyresultvalue', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + ] + }, + { value: 'key${variable2}${variable}', + result: 'keyresultvalue', + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ] + }, + { value: 'review/$CI_BUILD_REF_NAME', + result: 'review/feature/add-review-apps', + variables: [ + { key: 'CI_BUILD_REF_NAME', value: 'feature/add-review-apps' } + ] + }, + ] + + tests.each do |test| + context "#{test[:value]} resolves to #{test[:result]}" do + let(:value) { test[:value] } + let(:variables) { test[:variables] } + + it { is_expected.to eq(test[:result]) } + end + end + end +end diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb index 566035c60d0..0e85e302f29 100644 --- a/spec/lib/extracts_path_spec.rb +++ b/spec/lib/extracts_path_spec.rb @@ -6,6 +6,7 @@ describe ExtractsPath, lib: true do include Gitlab::Routing.url_helpers let(:project) { double('project') } + let(:request) { double('request') } before do @project = project @@ -15,9 +16,10 @@ describe ExtractsPath, lib: true do allow(project).to receive(:repository).and_return(repo) allow(project).to receive(:path_with_namespace). and_return('gitlab/gitlab-ci') + allow(request).to receive(:format=) end - describe '#assign_ref' do + describe '#assign_ref_vars' do let(:ref) { sample_commit[:id] } let(:params) { { path: sample_commit[:line_code_path], ref: ref } } @@ -25,18 +27,109 @@ describe ExtractsPath, lib: true do @project = create(:project) end - it "log tree path should have no escape sequences" do + it "log tree path has no escape sequences" do assign_ref_vars expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb") end - context 'escaped sequences in ref' do - let(:ref) { "improve%2Fawesome" } + context 'ref contains %20' do + let(:ref) { 'foo%20bar' } + + it 'is not converted to a space in @id' do + @project.repository.add_branch(@project.owner, 'foo%20bar', 'master') + + assign_ref_vars + + expect(@id).to start_with('foo%20bar/') + end + end + + context 'path contains space' do + let(:params) { { path: 'with space', ref: '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e' } } + + it 'is not converted to %20 in @path' do + assign_ref_vars + + expect(@path).to eq(params[:path]) + end + end + + context 'subclass overrides get_id' do + it 'uses ref returned by get_id' do + allow_any_instance_of(self.class).to receive(:get_id){ '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e' } - it "id should have no escape sequences" do assign_ref_vars - expect(@ref).to eq('improve/awesome') - expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb") + + expect(@id).to eq(get_id) + end + end + + context 'ref only exists without .atom suffix' do + context 'with a path' do + let(:params) { { ref: 'v1.0.0.atom', path: 'README.md' } } + + it 'renders a 404' do + expect(self).to receive(:render_404) + + assign_ref_vars + end + end + + context 'without a path' do + let(:params) { { ref: 'v1.0.0.atom' } } + before { assign_ref_vars } + + it 'sets the un-suffixed version as @ref' do + expect(@ref).to eq('v1.0.0') + end + + it 'sets the request format to Atom' do + expect(request).to have_received(:format=).with(:atom) + end + end + end + + context 'ref exists with .atom suffix' do + context 'with a path' do + let(:params) { { ref: 'master.atom', path: 'README.md' } } + + before do + repository = @project.repository + allow(repository).to receive(:commit).and_call_original + allow(repository).to receive(:commit).with('master.atom').and_return(repository.commit('master')) + + assign_ref_vars + end + + it 'sets the suffixed version as @ref' do + expect(@ref).to eq('master.atom') + end + + it 'does not change the request format' do + expect(request).not_to have_received(:format=) + end + end + + context 'without a path' do + let(:params) { { ref: 'master.atom' } } + + before do + repository = @project.repository + allow(repository).to receive(:commit).and_call_original + allow(repository).to receive(:commit).with('master.atom').and_return(repository.commit('master')) + end + + it 'sets the suffixed version as @ref' do + assign_ref_vars + + expect(@ref).to eq('master.atom') + end + + it 'does not change the request format' do + expect(request).not_to receive(:format=) + + assign_ref_vars + end end end end @@ -93,4 +186,18 @@ describe ExtractsPath, lib: true do end end end + + describe '#extract_ref_without_atom' do + it 'ignores any matching refs suffixed with atom' do + expect(extract_ref_without_atom('master.atom')).to eq('master') + end + + it 'returns the longest matching ref' do + expect(extract_ref_without_atom('release/app/v1.0.0.atom')).to eq('release/app/v1.0.0') + end + + it 'returns nil if there are no matching refs' do + expect(extract_ref_without_atom('foo.atom')).to eq(nil) + end + end end diff --git a/spec/lib/gitlab/akismet_helper_spec.rb b/spec/lib/gitlab/akismet_helper_spec.rb deleted file mode 100644 index b08396da4d2..00000000000 --- a/spec/lib/gitlab/akismet_helper_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'spec_helper' - -describe Gitlab::AkismetHelper, type: :helper do - let(:project) { create(:project, :public) } - let(:user) { create(:user) } - - before do - allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) - allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:akismet_api_key).and_return('12345') - end - - describe '#check_for_spam?' do - it 'returns true for public project' do - expect(helper.check_for_spam?(project)).to eq(true) - end - - it 'returns false for private project' do - project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) - expect(helper.check_for_spam?(project)).to eq(false) - end - end - - describe '#is_spam?' do - it 'returns true for spam' do - environment = { - 'action_dispatch.remote_ip' => '127.0.0.1', - 'HTTP_USER_AGENT' => 'Test User Agent' - } - - allow_any_instance_of(::Akismet::Client).to receive(:check).and_return([true, true]) - expect(helper.is_spam?(environment, user, 'Is this spam?')).to eq(true) - end - end -end diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 32ca8239845..4aba783dc33 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -8,7 +8,7 @@ module Gitlab let(:html) { 'H<sub>2</sub>O' } context "without project" do - it "should convert the input using Asciidoctor and default options" do + it "converts the input using Asciidoctor and default options" do expected_asciidoc_opts = { safe: :secure, backend: :html5, @@ -24,7 +24,7 @@ module Gitlab context "with asciidoc_opts" do let(:asciidoc_opts) { { safe: :safe, attributes: ['foo'] } } - it "should merge the options with default ones" do + it "merges the options with default ones" do expected_asciidoc_opts = { safe: :safe, backend: :html5, diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 7bec1367156..c9d64e99f88 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -4,14 +4,53 @@ describe Gitlab::Auth, lib: true do let(:gl_auth) { described_class } describe 'find_for_git_client' do - it 'recognizes CI' do - token = '123' + context 'build token' do + subject { gl_auth.find_for_git_client('gitlab-ci-token', build.token, project: project, ip: 'ip') } + + context 'for running build' do + let!(:build) { create(:ci_build, :running) } + let(:project) { build.project } + + before do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: 'gitlab-ci-token') + end + + it 'recognises user-less build' do + expect(subject).to eq(Gitlab::Auth::Result.new(nil, build.project, :ci, build_authentication_abilities)) + end + + it 'recognises user token' do + build.update(user: create(:user)) + + expect(subject).to eq(Gitlab::Auth::Result.new(build.user, build.project, :build, build_authentication_abilities)) + end + end + + (HasStatus::AVAILABLE_STATUSES - ['running']).each do |build_status| + context "for #{build_status} build" do + let!(:build) { create(:ci_build, status: build_status) } + let(:project) { build.project } + + before do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'gitlab-ci-token') + end + + it 'denies authentication' do + expect(subject).to eq(Gitlab::Auth::Result.new) + end + end + end + end + + it 'recognizes other ci services' do project = create(:empty_project) - project.update_attributes(runners_token: token, builds_enabled: true) + project.create_drone_ci_service(active: true) + project.drone_ci_service.update(token: 'token') + ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'gitlab-ci-token') - expect(gl_auth.find_for_git_client('gitlab-ci-token', token, project: project, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, :ci)) + expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'drone-ci-token') + expect(gl_auth.find_for_git_client('drone-ci-token', 'token', project: project, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)) end it 'recognizes master passwords' do @@ -19,7 +58,25 @@ describe Gitlab::Auth, lib: true do ip = 'ip' expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) - expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, :gitlab_or_ldap)) + expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) + end + + it 'recognizes user lfs tokens' do + user = create(:user) + ip = 'ip' + token = Gitlab::LfsToken.new(user).token + + expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) + expect(gl_auth.find_for_git_client(user.username, token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :lfs_token, full_authentication_abilities)) + end + + it 'recognizes deploy key lfs tokens' do + key = create(:deploy_key) + ip = 'ip' + token = Gitlab::LfsToken.new(key).token + + expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: "lfs+deploy-key-#{key.id}") + expect(gl_auth.find_for_git_client("lfs+deploy-key-#{key.id}", token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(key, nil, :lfs_deploy_token, read_authentication_abilities)) end it 'recognizes OAuth tokens' do @@ -29,7 +86,7 @@ describe Gitlab::Auth, lib: true do ip = 'ip' expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'oauth2') - expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, :oauth)) + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) end it 'returns double nil for invalid credentials' do @@ -51,24 +108,24 @@ describe Gitlab::Auth, lib: true do let(:username) { 'John' } # username isn't lowercase, test this let(:password) { 'my-secret' } - it "should find user by valid login/password" do + it "finds user by valid login/password" do expect( gl_auth.find_with_user_password(username, password) ).to eql user end - it 'should find user by valid email/password with case-insensitive email' do + it 'finds user by valid email/password with case-insensitive email' do expect(gl_auth.find_with_user_password(user.email.upcase, password)).to eql user end - it 'should find user by valid username/password with case-insensitive username' do + it 'finds user by valid username/password with case-insensitive username' do expect(gl_auth.find_with_user_password(username.upcase, password)).to eql user end - it "should not find user with invalid password" do + it "does not find user with invalid password" do password = 'wrong' expect( gl_auth.find_with_user_password(username, password) ).not_to eql user end - it "should not find user with invalid login" do + it "does not find user with invalid login" do user = 'wrong' expect( gl_auth.find_with_user_password(username, password) ).not_to eql user end @@ -91,4 +148,30 @@ describe Gitlab::Auth, lib: true do end end end + + private + + def build_authentication_abilities + [ + :read_project, + :build_download_code, + :build_read_container_image, + :build_create_container_image + ] + end + + def read_authentication_abilities + [ + :read_project, + :download_code, + :read_container_image + ] + end + + def full_authentication_abilities + read_authentication_abilities + [ + :push_code, + :create_container_image + ] + end end diff --git a/spec/lib/gitlab/backend/shell_spec.rb b/spec/lib/gitlab/backend/shell_spec.rb index 6e5ba211382..f826d0d1b04 100644 --- a/spec/lib/gitlab/backend/shell_spec.rb +++ b/spec/lib/gitlab/backend/shell_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'stringio' describe Gitlab::Shell, lib: true do let(:project) { double('Project', id: 7, path: 'diaspora') } @@ -21,15 +22,15 @@ describe Gitlab::Shell, lib: true do it { expect(gitlab_shell.url_to_repo('diaspora')).to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "diaspora.git") } - describe 'generate_and_link_secret_token' do + describe 'memoized secret_token' do let(:secret_file) { 'tmp/tests/.secret_shell_test' } let(:link_file) { 'tmp/tests/shell-secret-test/.gitlab_shell_secret' } before do - allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-secret-test') allow(Gitlab.config.gitlab_shell).to receive(:secret_file).and_return(secret_file) + allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-secret-test') FileUtils.mkdir('tmp/tests/shell-secret-test') - gitlab_shell.generate_and_link_secret_token + Gitlab::Shell.ensure_secret_token! end after do @@ -38,21 +39,47 @@ describe Gitlab::Shell, lib: true do end it 'creates and links the secret token file' do + secret_token = Gitlab::Shell.secret_token + expect(File.exist?(secret_file)).to be(true) + expect(File.read(secret_file).chomp).to eq(secret_token) expect(File.symlink?(link_file)).to be(true) expect(File.readlink(link_file)).to eq(secret_file) end end + describe '#add_key' do + it 'removes trailing garbage' do + allow(gitlab_shell).to receive(:gitlab_shell_keys_path).and_return(:gitlab_shell_keys_path) + expect(Gitlab::Utils).to receive(:system_silent).with( + [:gitlab_shell_keys_path, 'add-key', 'key-123', 'ssh-rsa foobar'] + ) + + gitlab_shell.add_key('key-123', 'ssh-rsa foobar trailing garbage') + end + end + describe Gitlab::Shell::KeyAdder, lib: true do describe '#add_key' do - it 'normalizes space characters in the key' do - io = spy + it 'removes trailing garbage' do + io = spy(:io) adder = described_class.new(io) - adder.add_key('key-42', "sha-rsa foo\tbar\tbaz") + adder.add_key('key-42', "ssh-rsa foo bar\tbaz") + + expect(io).to have_received(:puts).with("key-42\tssh-rsa foo") + end + + it 'raises an exception if the key contains a tab' do + expect do + described_class.new(StringIO.new).add_key('key-42', "ssh-rsa\tfoobar") + end.to raise_error(Gitlab::Shell::Error) + end - expect(io).to have_received(:puts).with("key-42\tsha-rsa foo bar baz") + it 'raises an exception if the key contains a newline' do + expect do + described_class.new(StringIO.new).add_key('key-42', "ssh-rsa foobar\nssh-rsa pawned") + end.to raise_error(Gitlab::Shell::Error) end end end diff --git a/spec/lib/gitlab/badge/build/metadata_spec.rb b/spec/lib/gitlab/badge/build/metadata_spec.rb new file mode 100644 index 00000000000..d678e522721 --- /dev/null +++ b/spec/lib/gitlab/badge/build/metadata_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' +require 'lib/gitlab/badge/shared/metadata' + +describe Gitlab::Badge::Build::Metadata do + let(:badge) { double(project: create(:project), ref: 'feature') } + let(:metadata) { described_class.new(badge) } + + it_behaves_like 'badge metadata' + + describe '#title' do + it 'returns build status title' do + expect(metadata.title).to eq 'build status' + end + end + + describe '#image_url' do + it 'returns valid url' do + expect(metadata.image_url).to include 'badges/feature/build.svg' + end + end + + describe '#link_url' do + it 'returns valid link' do + expect(metadata.link_url).to include 'commits/feature' + end + end +end diff --git a/spec/lib/gitlab/badge/build/status_spec.rb b/spec/lib/gitlab/badge/build/status_spec.rb new file mode 100644 index 00000000000..38eebb2a176 --- /dev/null +++ b/spec/lib/gitlab/badge/build/status_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe Gitlab::Badge::Build::Status do + let(:project) { create(:project) } + let(:sha) { project.commit.sha } + let(:branch) { 'master' } + let(:badge) { described_class.new(project, branch) } + + describe '#entity' do + it 'always says build' do + expect(badge.entity).to eq 'build' + end + end + + describe '#template' do + it 'returns badge template' do + expect(badge.template.key_text).to eq 'build' + end + end + + describe '#metadata' do + it 'returns badge metadata' do + expect(badge.metadata.image_url) + .to include 'badges/master/build.svg' + end + end + + context 'build exists' do + let!(:build) { create_build(project, sha, branch) } + + context 'build success' do + before { build.success! } + + describe '#status' do + it 'is successful' do + expect(badge.status).to eq 'success' + end + end + end + + context 'build failed' do + before { build.drop! } + + describe '#status' do + it 'failed' do + expect(badge.status).to eq 'failed' + end + end + end + + context 'when outdated pipeline for given ref exists' do + before do + build.success! + + old_build = create_build(project, '11eeffdd', branch) + old_build.drop! + end + + it 'does not take outdated pipeline into account' do + expect(badge.status).to eq 'success' + end + end + + context 'when multiple pipelines exist for given sha' do + before do + build.drop! + + new_build = create_build(project, sha, branch) + new_build.success! + end + + it 'reports the compound status' do + expect(badge.status).to eq 'failed' + end + end + end + + context 'build does not exist' do + describe '#status' do + it 'is unknown' do + expect(badge.status).to eq 'unknown' + end + end + end + + def create_build(project, sha, branch) + pipeline = create(:ci_empty_pipeline, + project: project, + sha: sha, + ref: branch) + + create(:ci_build, pipeline: pipeline, stage: 'notify') + end +end diff --git a/spec/lib/gitlab/badge/build/template_spec.rb b/spec/lib/gitlab/badge/build/template_spec.rb new file mode 100644 index 00000000000..a7e21fb8bb1 --- /dev/null +++ b/spec/lib/gitlab/badge/build/template_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +describe Gitlab::Badge::Build::Template do + let(:badge) { double(entity: 'build', status: 'success') } + let(:template) { described_class.new(badge) } + + describe '#key_text' do + it 'is always says build' do + expect(template.key_text).to eq 'build' + end + end + + describe '#value_text' do + it 'is status value' do + expect(template.value_text).to eq 'success' + end + end + + describe 'widths and text anchors' do + it 'has fixed width and text anchors' do + expect(template.width).to eq 92 + expect(template.key_width).to eq 38 + expect(template.value_width).to eq 54 + expect(template.key_text_anchor).to eq 19 + expect(template.value_text_anchor).to eq 65 + end + end + + describe '#key_color' do + it 'is always the same' do + expect(template.key_color).to eq '#555' + end + end + + describe '#value_color' do + context 'when status is success' do + it 'has expected color' do + expect(template.value_color).to eq '#4c1' + end + end + + context 'when status is failed' do + before do + allow(badge).to receive(:status).and_return('failed') + end + + it 'has expected color' do + expect(template.value_color).to eq '#e05d44' + end + end + + context 'when status is running' do + before do + allow(badge).to receive(:status).and_return('running') + end + + it 'has expected color' do + expect(template.value_color).to eq '#dfb317' + end + end + + context 'when status is unknown' do + before do + allow(badge).to receive(:status).and_return('unknown') + end + + it 'has expected color' do + expect(template.value_color).to eq '#9f9f9f' + end + end + + context 'when status does not match any known statuses' do + before do + allow(badge).to receive(:status).and_return('invalid') + end + + it 'has expected color' do + expect(template.value_color).to eq '#9f9f9f' + end + end + end +end diff --git a/spec/lib/gitlab/badge/build_spec.rb b/spec/lib/gitlab/badge/build_spec.rb deleted file mode 100644 index f3b522a02f5..00000000000 --- a/spec/lib/gitlab/badge/build_spec.rb +++ /dev/null @@ -1,123 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Badge::Build do - let(:project) { create(:project) } - let(:sha) { project.commit.sha } - let(:branch) { 'master' } - let(:badge) { described_class.new(project, branch) } - - describe '#type' do - subject { badge.type } - it { is_expected.to eq 'image/svg+xml' } - end - - describe '#to_html' do - let(:html) { Nokogiri::HTML.parse(badge.to_html) } - let(:a_href) { html.at('a') } - - it 'points to link' do - expect(a_href[:href]).to eq badge.link_url - end - - it 'contains clickable image' do - expect(a_href.children.first.name).to eq 'img' - end - end - - describe '#to_markdown' do - subject { badge.to_markdown } - - it { is_expected.to include badge.image_url } - it { is_expected.to include badge.link_url } - end - - describe '#image_url' do - subject { badge.image_url } - it { is_expected.to include "badges/#{branch}/build.svg" } - end - - describe '#link_url' do - subject { badge.link_url } - it { is_expected.to include "commits/#{branch}" } - end - - context 'build exists' do - let!(:build) { create_build(project, sha, branch) } - - context 'build success' do - before { build.success! } - - describe '#to_s' do - subject { badge.to_s } - it { is_expected.to eq 'build-success' } - end - - describe '#data' do - let(:data) { badge.data } - - it 'contains information about success' do - expect(status_node(data, 'success')).to be_truthy - end - end - end - - context 'build failed' do - before { build.drop! } - - describe '#to_s' do - subject { badge.to_s } - it { is_expected.to eq 'build-failed' } - end - - describe '#data' do - let(:data) { badge.data } - - it 'contains information about failure' do - expect(status_node(data, 'failed')).to be_truthy - end - end - end - end - - context 'build does not exist' do - describe '#to_s' do - subject { badge.to_s } - it { is_expected.to eq 'build-unknown' } - end - - describe '#data' do - let(:data) { badge.data } - - it 'contains infromation about unknown build' do - expect(status_node(data, 'unknown')).to be_truthy - end - end - end - - context 'when outdated pipeline for given ref exists' do - before do - build = create_build(project, sha, branch) - build.success! - - old_build = create_build(project, '11eeffdd', branch) - old_build.drop! - end - - it 'does not take outdated pipeline into account' do - expect(badge.to_s).to eq 'build-success' - end - end - - def create_build(project, sha, branch) - pipeline = create(:ci_pipeline, project: project, - sha: sha, - ref: branch) - - create(:ci_build, pipeline: pipeline, stage: 'notify') - end - - def status_node(data, status) - xml = Nokogiri::XML.parse(data) - xml.at(%Q{text:contains("#{status}")}) - end -end diff --git a/spec/lib/gitlab/badge/coverage/metadata_spec.rb b/spec/lib/gitlab/badge/coverage/metadata_spec.rb new file mode 100644 index 00000000000..74eaf7eaf8b --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/metadata_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' +require 'lib/gitlab/badge/shared/metadata' + +describe Gitlab::Badge::Coverage::Metadata do + let(:badge) do + double(project: create(:project), ref: 'feature', job: 'test') + end + + let(:metadata) { described_class.new(badge) } + + it_behaves_like 'badge metadata' + + describe '#title' do + it 'returns coverage report title' do + expect(metadata.title).to eq 'coverage report' + end + end + + describe '#image_url' do + it 'returns valid url' do + expect(metadata.image_url).to include 'badges/feature/coverage.svg' + end + end + + describe '#link_url' do + it 'returns valid link' do + expect(metadata.link_url).to include 'commits/feature' + end + end +end diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb new file mode 100644 index 00000000000..1547bd3228c --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/report_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +describe Gitlab::Badge::Coverage::Report do + let(:project) { create(:project) } + let(:job_name) { nil } + + let(:badge) do + described_class.new(project, 'master', job_name) + end + + describe '#entity' do + it 'describes a coverage' do + expect(badge.entity).to eq 'coverage' + end + end + + describe '#metadata' do + it 'returns correct metadata' do + expect(badge.metadata.image_url).to include 'coverage.svg' + end + end + + describe '#template' do + it 'returns correct template' do + expect(badge.template.key_text).to eq 'coverage' + end + end + + shared_examples 'unknown coverage report' do + context 'particular job specified' do + let(:job_name) { '' } + + it 'returns nil' do + expect(badge.status).to be_nil + end + end + + context 'particular job not specified' do + let(:job_name) { nil } + + it 'returns nil' do + expect(badge.status).to be_nil + end + end + end + + context 'when latest successful pipeline exists' do + before do + create_pipeline do |pipeline| + create(:ci_build, :success, pipeline: pipeline, name: 'first', coverage: 40) + create(:ci_build, :success, pipeline: pipeline, coverage: 60) + end + + create_pipeline do |pipeline| + create(:ci_build, :failed, pipeline: pipeline, coverage: 10) + end + end + + context 'when particular job specified' do + let(:job_name) { 'first' } + + it 'returns coverage for the particular job' do + expect(badge.status).to eq 40 + end + end + + context 'when particular job not specified' do + let(:job_name) { '' } + + it 'returns arithemetic mean for the pipeline' do + expect(badge.status).to eq 50 + end + end + end + + context 'when only failed pipeline exists' do + before do + create_pipeline do |pipeline| + create(:ci_build, :failed, pipeline: pipeline, coverage: 10) + end + end + + it_behaves_like 'unknown coverage report' + + context 'particular job specified' do + let(:job_name) { 'nonexistent' } + + it 'retruns nil' do + expect(badge.status).to be_nil + end + end + end + + context 'pipeline does not exist' do + it_behaves_like 'unknown coverage report' + end + + def create_pipeline + opts = { project: project, sha: project.commit.id, ref: 'master' } + + create(:ci_pipeline, opts).tap do |pipeline| + yield pipeline + pipeline.update_status + end + end +end diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb new file mode 100644 index 00000000000..383bae6e087 --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/template_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe Gitlab::Badge::Coverage::Template do + let(:badge) { double(entity: 'coverage', status: 90) } + let(:template) { described_class.new(badge) } + + describe '#key_text' do + it 'is always says coverage' do + expect(template.key_text).to eq 'coverage' + end + end + + describe '#value_text' do + context 'when coverage is known' do + it 'returns coverage percentage' do + expect(template.value_text).to eq '90%' + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'returns string that says coverage is unknown' do + expect(template.value_text).to eq 'unknown' + end + end + end + + describe '#key_width' do + it 'has a fixed key width' do + expect(template.key_width).to eq 62 + end + end + + describe '#value_width' do + context 'when coverage is known' do + it 'is narrower when coverage is known' do + expect(template.value_width).to eq 36 + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'is wider when coverage is unknown to fit text' do + expect(template.value_width).to eq 58 + end + end + end + + describe '#key_color' do + it 'always has the same color' do + expect(template.key_color).to eq '#555' + end + end + + describe '#value_color' do + context 'when coverage is good' do + before do + allow(badge).to receive(:status).and_return(98) + end + + it 'is green' do + expect(template.value_color).to eq '#4c1' + end + end + + context 'when coverage is acceptable' do + before do + allow(badge).to receive(:status).and_return(90) + end + + it 'is green-orange' do + expect(template.value_color).to eq '#a3c51c' + end + end + + context 'when coverage is medium' do + before do + allow(badge).to receive(:status).and_return(75) + end + + it 'is orange-yellow' do + expect(template.value_color).to eq '#dfb317' + end + end + + context 'when coverage is low' do + before do + allow(badge).to receive(:status).and_return(50) + end + + it 'is red' do + expect(template.value_color).to eq '#e05d44' + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'is grey' do + expect(template.value_color).to eq '#9f9f9f' + end + end + end + + describe '#width' do + context 'when coverage is known' do + it 'returns the key width plus value width' do + expect(template.width).to eq 98 + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'returns key width plus wider value width' do + expect(template.width).to eq 120 + end + end + end +end diff --git a/spec/lib/gitlab/badge/shared/metadata.rb b/spec/lib/gitlab/badge/shared/metadata.rb new file mode 100644 index 00000000000..0cf18514251 --- /dev/null +++ b/spec/lib/gitlab/badge/shared/metadata.rb @@ -0,0 +1,21 @@ +shared_examples 'badge metadata' do + describe '#to_html' do + let(:html) { Nokogiri::HTML.parse(metadata.to_html) } + let(:a_href) { html.at('a') } + + it 'points to link' do + expect(a_href[:href]).to eq metadata.link_url + end + + it 'contains clickable image' do + expect(a_href.children.first.name).to eq 'img' + end + end + + describe '#to_markdown' do + subject { metadata.to_markdown } + + it { is_expected.to include metadata.image_url } + it { is_expected.to include metadata.link_url } + end +end diff --git a/spec/lib/gitlab/changes_list_spec.rb b/spec/lib/gitlab/changes_list_spec.rb new file mode 100644 index 00000000000..69d86144e32 --- /dev/null +++ b/spec/lib/gitlab/changes_list_spec.rb @@ -0,0 +1,30 @@ +require "spec_helper" + +describe Gitlab::ChangesList do + let(:valid_changes_string) { "\n000000 570e7b2 refs/heads/my_branch\nd14d6c 6fd24d refs/heads/master" } + let(:invalid_changes) { 1 } + + context 'when changes is a valid string' do + let(:changes_list) { Gitlab::ChangesList.new(valid_changes_string) } + + it 'splits elements by newline character' do + expect(changes_list).to contain_exactly({ + oldrev: "000000", + newrev: "570e7b2", + ref: "refs/heads/my_branch" + }, { + oldrev: "d14d6c", + newrev: "6fd24d", + ref: "refs/heads/master" + }) + end + + it 'behaves like a list' do + expect(changes_list.first).to eq({ + oldrev: "000000", + newrev: "570e7b2", + ref: "refs/heads/my_branch" + }) + end + end +end diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb new file mode 100644 index 00000000000..39069b49978 --- /dev/null +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe Gitlab::Checks::ChangeAccess, lib: true do + describe '#exec' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:user_access) { Gitlab::UserAccess.new(user, project: project) } + let(:changes) do + { + oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', + newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51', + ref: 'refs/heads/master' + } + end + + subject { described_class.new(changes, project: project, user_access: user_access).exec } + + before { allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) } + + context 'without failed checks' do + it "doesn't return any error" do + expect(subject.status).to be(true) + end + end + + context 'when the user is not allowed to push code' do + it 'returns an error' do + expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to push code to this project.') + end + end + + context 'tags check' do + let(:changes) do + { + oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', + newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51', + ref: 'refs/tags/v1.0.0' + } + end + + it 'returns an error if the user is not allowed to update tags' do + expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to change existing tags on this project.') + end + end + + context 'protected branches check' do + before do + allow(project).to receive(:protected_branch?).with('master').and_return(true) + end + + 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.') + end + + it 'returns an error if the user is not allowed to merge to protected branches' do + expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true) + expect(user_access).to receive(:can_merge_to_branch?).and_return(false) + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to merge code into protected branches on this project.') + end + + it 'returns an error if the user is not allowed to push to protected branches' do + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to push code to protected branches on this project.') + end + + context 'branch deletion' do + let(:changes) do + { + oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', + newrev: '0000000000000000000000000000000000000000', + ref: 'refs/heads/master' + } + 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 + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/cache_spec.rb b/spec/lib/gitlab/ci/config/node/cache_spec.rb index 50f619ce26e..e251210949c 100644 --- a/spec/lib/gitlab/ci/config/node/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/node/cache_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::Ci::Config::Node::Cache do let(:entry) { described_class.new(config) } describe 'validations' do - before { entry.process! } + before { entry.compose! } context 'when entry config value is correct' do let(:config) do diff --git a/spec/lib/gitlab/ci/config/node/environment_spec.rb b/spec/lib/gitlab/ci/config/node/environment_spec.rb new file mode 100644 index 00000000000..df453223da7 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/environment_spec.rb @@ -0,0 +1,155 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Environment do + let(:entry) { described_class.new(config) } + + before { entry.compose! } + + context 'when configuration is a string' do + let(:config) { 'production' } + + describe '#string?' do + it 'is string configuration' do + expect(entry).to be_string + end + end + + describe '#hash?' do + it 'is not hash configuration' do + expect(entry).not_to be_hash + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + + describe '#value' do + it 'returns valid hash' do + expect(entry.value).to eq(name: 'production') + end + end + + describe '#name' do + it 'returns environment name' do + expect(entry.name).to eq 'production' + end + end + + describe '#url' do + it 'returns environment url' do + expect(entry.url).to be_nil + end + end + end + + context 'when configuration is a hash' do + let(:config) do + { name: 'development', url: 'https://example.gitlab.com' } + end + + describe '#string?' do + it 'is not string configuration' do + expect(entry).not_to be_string + end + end + + describe '#hash?' do + it 'is hash configuration' do + expect(entry).to be_hash + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + + describe '#value' do + it 'returns valid hash' do + expect(entry.value).to eq config + end + end + + describe '#name' do + it 'returns environment name' do + expect(entry.name).to eq 'development' + end + end + + describe '#url' do + it 'returns environment url' do + expect(entry.url).to eq 'https://example.gitlab.com' + end + end + end + + context 'when variables are used for environment' do + let(:config) do + { name: 'review/$CI_BUILD_REF_NAME', + url: 'https://$CI_BUILD_REF_NAME.review.gitlab.com' } + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when configuration is invalid' do + context 'when configuration is an array' do + let(:config) { ['env'] } + + describe '#valid?' do + it 'is not valid' do + expect(entry).not_to be_valid + end + end + + describe '#errors' do + it 'contains error about invalid type' do + expect(entry.errors) + .to include 'environment config should be a hash or a string' + end + end + end + + context 'when environment name is not present' do + let(:config) { { url: 'https://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 missing environment name' do + expect(entry.errors) + .to include "environment name can't be blank" + 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/ci/config/node/factory_spec.rb b/spec/lib/gitlab/ci/config/node/factory_spec.rb index d26185ba585..a699089c563 100644 --- a/spec/lib/gitlab/ci/config/node/factory_spec.rb +++ b/spec/lib/gitlab/ci/config/node/factory_spec.rb @@ -65,7 +65,8 @@ describe Gitlab::Ci::Config::Node::Factory do .value(nil) .create! - expect(entry).to be_an_instance_of Gitlab::Ci::Config::Node::Undefined + expect(entry) + .to be_an_instance_of Gitlab::Ci::Config::Node::Unspecified end end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 2f87d270b36..12232ff7e2f 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -14,7 +14,7 @@ describe Gitlab::Ci::Config::Node::Global do end context 'when hash is valid' do - context 'when all entries defined' do + context 'when some entries defined' do let(:hash) do { before_script: ['ls', 'pwd'], image: 'ruby:2.2', @@ -24,11 +24,11 @@ describe Gitlab::Ci::Config::Node::Global do stages: ['build', 'pages'], cache: { key: 'k', untracked: true, paths: ['public/'] }, rspec: { script: %w[rspec ls] }, - spinach: { script: 'spinach' } } + spinach: { before_script: [], variables: {}, script: 'spinach' } } end - describe '#process!' do - before { global.process! } + describe '#compose!' do + before { global.compose! } it 'creates nodes hash' do expect(global.descendants).to be_an Array @@ -59,7 +59,7 @@ describe Gitlab::Ci::Config::Node::Global do end end - context 'when not processed' do + context 'when not composed' do describe '#before_script' do it 'returns nil' do expect(global.before_script).to be nil @@ -73,8 +73,14 @@ describe Gitlab::Ci::Config::Node::Global do end end - context 'when processed' do - before { global.process! } + context 'when composed' do + before { global.compose! } + + describe '#errors' do + it 'has no errors' do + expect(global.errors).to be_empty + end + end describe '#before_script' do it 'returns correct script' do @@ -137,10 +143,24 @@ describe Gitlab::Ci::Config::Node::Global do expect(global.jobs).to eq( rspec: { name: :rspec, script: %w[rspec ls], - stage: 'test' }, + before_script: ['ls', 'pwd'], + commands: "ls\npwd\nrspec\nls", + image: 'ruby:2.2', + services: ['postgres:9.1', 'mysql:5.5'], + stage: 'test', + cache: { key: 'k', untracked: true, paths: ['public/'] }, + variables: { VAR: 'value' }, + after_script: ['make clean'] }, spinach: { name: :spinach, + before_script: [], script: %w[spinach], - stage: 'test' } + commands: 'spinach', + image: 'ruby:2.2', + services: ['postgres:9.1', 'mysql:5.5'], + stage: 'test', + cache: { key: 'k', untracked: true, paths: ['public/'] }, + variables: {}, + after_script: ['make clean'] }, ) end end @@ -148,17 +168,20 @@ describe Gitlab::Ci::Config::Node::Global do end context 'when most of entires not defined' do - let(:hash) { { cache: { key: 'a' }, rspec: { script: %w[ls] } } } - before { global.process! } + before { global.compose! } + + let(:hash) do + { cache: { key: 'a' }, rspec: { script: %w[ls] } } + end describe '#nodes' do it 'instantizes all nodes' do expect(global.descendants.count).to eq 8 end - it 'contains undefined nodes' do + it 'contains unspecified nodes' do expect(global.descendants.first) - .to be_an_instance_of Gitlab::Ci::Config::Node::Undefined + .to be_an_instance_of Gitlab::Ci::Config::Node::Unspecified end end @@ -188,8 +211,11 @@ describe Gitlab::Ci::Config::Node::Global do # details. # context 'when entires specified but not defined' do - let(:hash) { { variables: nil, rspec: { script: 'rspec' } } } - before { global.process! } + before { global.compose! } + + let(:hash) do + { variables: nil, rspec: { script: 'rspec' } } + end describe '#variables' do it 'undefined entry returns a default value' do @@ -200,7 +226,7 @@ describe Gitlab::Ci::Config::Node::Global do end context 'when hash is not valid' do - before { global.process! } + before { global.compose! } let(:hash) do { before_script: 'ls' } @@ -247,4 +273,27 @@ describe Gitlab::Ci::Config::Node::Global do expect(global.specified?).to be true end end + + describe '#[]' do + before { global.compose! } + + let(:hash) do + { cache: { key: 'a' }, rspec: { script: 'ls' } } + end + + context 'when node exists' do + it 'returns correct entry' do + expect(global[:cache]) + .to be_an_instance_of Gitlab::Ci::Config::Node::Cache + expect(global[:jobs][:rspec][:script].value).to eq ['ls'] + end + end + + context 'when node does not exist' do + it 'always return unspecified node' do + expect(global[:some][:unknown][:node]) + .not_to be_specified + end + end + end end diff --git a/spec/lib/gitlab/ci/config/node/hidden_job_spec.rb b/spec/lib/gitlab/ci/config/node/hidden_spec.rb index cc44e2cc054..61e2a554419 100644 --- a/spec/lib/gitlab/ci/config/node/hidden_job_spec.rb +++ b/spec/lib/gitlab/ci/config/node/hidden_spec.rb @@ -1,15 +1,15 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Node::HiddenJob do +describe Gitlab::Ci::Config::Node::Hidden do let(:entry) { described_class.new(config) } describe 'validations' do context 'when entry config value is correct' do - let(:config) { { image: 'ruby:2.2' } } + let(:config) { [:some, :array] } describe '#value' do it 'returns key value' do - expect(entry.value).to eq(image: 'ruby:2.2') + expect(entry.value).to eq [:some, :array] end end @@ -21,17 +21,6 @@ describe Gitlab::Ci::Config::Node::HiddenJob do end context 'when entry value is not correct' do - context 'incorrect config value type' do - let(:config) { ['incorrect'] } - - describe '#errors' do - it 'saves errors' do - expect(entry.errors) - .to include 'hidden job config should be a hash' - end - end - end - context 'when config is empty' do let(:config) { {} } diff --git a/spec/lib/gitlab/ci/config/node/job_spec.rb b/spec/lib/gitlab/ci/config/node/job_spec.rb index 1484fb60dd8..91f676dae03 100644 --- a/spec/lib/gitlab/ci/config/node/job_spec.rb +++ b/spec/lib/gitlab/ci/config/node/job_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Job do let(:entry) { described_class.new(config, name: :rspec) } - before { entry.process! } - describe 'validations' do + before { entry.compose! } + context 'when entry config value is correct' do let(:config) { { script: 'rspec' } } @@ -59,28 +59,82 @@ describe Gitlab::Ci::Config::Node::Job do end end - describe '#value' do - context 'when entry is correct' do + describe '#relevant?' do + it 'is a relevant entry' do + expect(entry).to be_relevant + end + end + + describe '#compose!' do + let(:unspecified) { double('unspecified', 'specified?' => false) } + + let(:specified) do + double('specified', 'specified?' => true, value: 'specified') + end + + let(:deps) { double('deps', '[]' => unspecified) } + + context 'when job config overrides global config' do + before { entry.compose!(deps) } + let(:config) do - { before_script: %w[ls pwd], - script: 'rspec', - after_script: %w[cleanup] } + { image: 'some_image', cache: { key: 'test' } } + end + + it 'overrides global config' do + expect(entry[:image].value).to eq 'some_image' + expect(entry[:cache].value).to eq(key: 'test') + end + end + + context 'when job config does not override global config' do + before do + allow(deps).to receive('[]').with(:image).and_return(specified) + entry.compose!(deps) end - it 'returns correct value' do - expect(entry.value) - .to eq(name: :rspec, - before_script: %w[ls pwd], - script: %w[rspec], - stage: 'test', - after_script: %w[cleanup]) + let(:config) { { script: 'ls', cache: { key: 'test' } } } + + it 'uses config from global entry' do + expect(entry[:image].value).to eq 'specified' + expect(entry[:cache].value).to eq(key: 'test') end end end - describe '#relevant?' do - it 'is a relevant entry' do - expect(entry).to be_relevant + context 'when composed' do + before { entry.compose! } + + describe '#value' do + before { entry.compose! } + + context 'when entry is correct' do + let(:config) do + { before_script: %w[ls pwd], + script: 'rspec', + after_script: %w[cleanup] } + end + + it 'returns correct value' do + expect(entry.value) + .to eq(name: :rspec, + before_script: %w[ls pwd], + script: %w[rspec], + commands: "ls\npwd\nrspec", + stage: 'test', + after_script: %w[cleanup]) + end + end + end + + describe '#commands' do + let(:config) do + { before_script: %w[ls pwd], script: 'rspec' } + end + + it 'returns a string of commands concatenated with new line character' do + expect(entry.commands).to eq "ls\npwd\nrspec" + end end end end diff --git a/spec/lib/gitlab/ci/config/node/jobs_spec.rb b/spec/lib/gitlab/ci/config/node/jobs_spec.rb index b8d9c70479c..929809339ef 100644 --- a/spec/lib/gitlab/ci/config/node/jobs_spec.rb +++ b/spec/lib/gitlab/ci/config/node/jobs_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::Ci::Config::Node::Jobs do let(:entry) { described_class.new(config) } describe 'validations' do - before { entry.process! } + before { entry.compose! } context 'when entry config value is correct' do let(:config) { { rspec: { script: 'rspec' } } } @@ -47,8 +47,8 @@ describe Gitlab::Ci::Config::Node::Jobs do end end - context 'when valid job entries processed' do - before { entry.process! } + context 'when valid job entries composed' do + before { entry.compose! } let(:config) do { rspec: { script: 'rspec' }, @@ -61,9 +61,11 @@ describe Gitlab::Ci::Config::Node::Jobs do expect(entry.value).to eq( rspec: { name: :rspec, script: %w[rspec], + commands: 'rspec', stage: 'test' }, spinach: { name: :spinach, script: %w[spinach], + commands: 'spinach', stage: 'test' }) end end @@ -74,7 +76,7 @@ describe Gitlab::Ci::Config::Node::Jobs do expect(entry.descendants.first(2)) .to all(be_an_instance_of(Gitlab::Ci::Config::Node::Job)) expect(entry.descendants.last) - .to be_an_instance_of(Gitlab::Ci::Config::Node::HiddenJob) + .to be_an_instance_of(Gitlab::Ci::Config::Node::Hidden) end end diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb deleted file mode 100644 index 1ab5478dcfa..00000000000 --- a/spec/lib/gitlab/ci/config/node/null_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Ci::Config::Node::Null do - let(:null) { described_class.new(nil) } - - describe '#leaf?' do - it 'is leaf node' do - expect(null).to be_leaf - end - end - - describe '#valid?' do - it 'is always valid' do - expect(null).to be_valid - end - end - - describe '#errors' do - it 'is does not contain errors' do - expect(null.errors).to be_empty - end - end - - describe '#value' do - it 'returns nil' do - expect(null.value).to eq nil - end - end - - describe '#relevant?' do - it 'is not relevant' do - expect(null.relevant?).to eq false - end - end - - describe '#specified?' do - it 'is not defined' do - expect(null.specified?).to eq false - end - end -end diff --git a/spec/lib/gitlab/ci/config/node/script_spec.rb b/spec/lib/gitlab/ci/config/node/script_spec.rb index ee7395362a9..219a7e981d3 100644 --- a/spec/lib/gitlab/ci/config/node/script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/script_spec.rb @@ -3,9 +3,7 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Script do let(:entry) { described_class.new(config) } - describe '#process!' do - before { entry.process! } - + describe 'validations' do context 'when entry config value is correct' do let(:config) { ['ls', 'pwd'] } diff --git a/spec/lib/gitlab/ci/config/node/undefined_spec.rb b/spec/lib/gitlab/ci/config/node/undefined_spec.rb index 2d43e1c1a9d..6bde8602963 100644 --- a/spec/lib/gitlab/ci/config/node/undefined_spec.rb +++ b/spec/lib/gitlab/ci/config/node/undefined_spec.rb @@ -1,32 +1,41 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Undefined do - let(:undefined) { described_class.new(entry) } - let(:entry) { spy('Entry') } + let(:entry) { described_class.new } + + describe '#leaf?' do + it 'is leaf node' do + expect(entry).to be_leaf + end + end describe '#valid?' do - it 'delegates method to entry' do - expect(undefined.valid).to eq entry + it 'is always valid' do + expect(entry).to be_valid end end describe '#errors' do - it 'delegates method to entry' do - expect(undefined.errors).to eq entry + it 'is does not contain errors' do + expect(entry.errors).to be_empty end end describe '#value' do - it 'delegates method to entry' do - expect(undefined.value).to eq entry + it 'returns nil' do + expect(entry.value).to eq nil end end - describe '#specified?' do - it 'is always false' do - allow(entry).to receive(:specified?).and_return(true) + describe '#relevant?' do + it 'is not relevant' do + expect(entry.relevant?).to eq false + end + end - expect(undefined.specified?).to be false + describe '#specified?' do + it 'is not defined' do + expect(entry.specified?).to eq false end end end diff --git a/spec/lib/gitlab/ci/config/node/unspecified_spec.rb b/spec/lib/gitlab/ci/config/node/unspecified_spec.rb new file mode 100644 index 00000000000..ba3ceef24ce --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/unspecified_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Unspecified do + let(:unspecified) { described_class.new(entry) } + let(:entry) { spy('Entry') } + + describe '#valid?' do + it 'delegates method to entry' do + expect(unspecified.valid?).to eq entry + end + end + + describe '#errors' do + it 'delegates method to entry' do + expect(unspecified.errors).to eq entry + end + end + + describe '#value' do + it 'delegates method to entry' do + expect(unspecified.value).to eq entry + end + end + + describe '#specified?' do + it 'is always false' do + allow(entry).to receive(:specified?).and_return(true) + + expect(unspecified.specified?).to be false + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/validatable_spec.rb b/spec/lib/gitlab/ci/config/node/validatable_spec.rb index 10cd01afcd1..64b77fd6e03 100644 --- a/spec/lib/gitlab/ci/config/node/validatable_spec.rb +++ b/spec/lib/gitlab/ci/config/node/validatable_spec.rb @@ -23,6 +23,10 @@ describe Gitlab::Ci::Config::Node::Validatable do .to be Gitlab::Ci::Config::Node::Validator end + it 'returns only one validator to mitigate leaks' do + expect { node.validator }.not_to change { node.validator } + end + context 'when validating node instance' do let(:node_instance) { node.new } diff --git a/spec/lib/gitlab/ci/pipeline_duration_spec.rb b/spec/lib/gitlab/ci/pipeline_duration_spec.rb new file mode 100644 index 00000000000..b26728a843c --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline_duration_spec.rb @@ -0,0 +1,115 @@ +require 'spec_helper' + +describe Gitlab::Ci::PipelineDuration do + let(:calculated_duration) { calculate(data) } + + shared_examples 'calculating duration' do + it do + expect(calculated_duration).to eq(duration) + end + end + + context 'test sample A' do + let(:data) do + [[0, 1], + [1, 2], + [3, 4], + [5, 6]] + end + + let(:duration) { 4 } + + it_behaves_like 'calculating duration' + end + + context 'test sample B' do + let(:data) do + [[0, 1], + [1, 2], + [2, 3], + [3, 4], + [0, 4]] + end + + let(:duration) { 4 } + + it_behaves_like 'calculating duration' + end + + context 'test sample C' do + let(:data) do + [[0, 4], + [2, 6], + [5, 7], + [8, 9]] + end + + let(:duration) { 8 } + + it_behaves_like 'calculating duration' + end + + context 'test sample D' do + let(:data) do + [[0, 1], + [2, 3], + [4, 5], + [6, 7]] + end + + let(:duration) { 4 } + + it_behaves_like 'calculating duration' + end + + context 'test sample E' do + let(:data) do + [[0, 1], + [3, 9], + [3, 4], + [3, 5], + [3, 8], + [4, 5], + [4, 7], + [5, 8]] + end + + let(:duration) { 7 } + + it_behaves_like 'calculating duration' + end + + context 'test sample F' do + let(:data) do + [[1, 3], + [2, 4], + [2, 4], + [2, 4], + [5, 8]] + end + + let(:duration) { 6 } + + it_behaves_like 'calculating duration' + end + + context 'test sample G' do + let(:data) do + [[1, 3], + [2, 4], + [6, 7]] + end + + let(:duration) { 4 } + + it_behaves_like 'calculating duration' + end + + def calculate(data) + periods = data.shuffle.map do |(first, last)| + Gitlab::Ci::PipelineDuration::Period.new(first, last) + end + + Gitlab::Ci::PipelineDuration.from_periods(periods.sort_by(&:first)) + end +end diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index e9b8ce6b5bb..de3f64249a2 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -3,10 +3,12 @@ require 'spec_helper' describe Gitlab::ClosingIssueExtractor, lib: true do let(:project) { create(:project) } let(:project2) { create(:project) } + let(:forked_project) { Projects::ForkService.new(project, project.creator).execute } let(:issue) { create(:issue, project: project) } let(:issue2) { create(:issue, project: project2) } let(:reference) { issue.to_reference } let(:cross_reference) { issue2.to_reference(project) } + let(:fork_cross_reference) { issue.to_reference(forked_project) } subject { described_class.new(project, project.creator) } @@ -278,6 +280,15 @@ describe Gitlab::ClosingIssueExtractor, lib: true do end end + context "with a cross-project fork reference" do + subject { described_class.new(forked_project, forked_project.creator) } + + it do + message = "Closes #{fork_cross_reference}" + expect(subject.closed_by_message(message)).to be_empty + end + end + context "with an invalid URL" do it do message = "Closes https://google.com#{urls.namespace_project_issue_path(issue2.project.namespace, issue2.project, issue2)}" diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb new file mode 100644 index 00000000000..39d892c18c0 --- /dev/null +++ b/spec/lib/gitlab/conflict/file_collection_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Gitlab::Conflict::FileCollection, lib: true do + let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') } + let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) } + + describe '#files' do + it 'returns an array of Conflict::Files' do + expect(file_collection.files).to all(be_an_instance_of(Gitlab::Conflict::File)) + end + end + + describe '#default_commit_message' do + it 'matches the format of the git CLI commit message' do + expect(file_collection.default_commit_message).to eq(<<EOM.chomp) +Merge branch 'conflict-start' into 'conflict-resolvable' + +# Conflicts: +# files/ruby/popen.rb +# files/ruby/regex.rb +EOM + end + end +end diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb new file mode 100644 index 00000000000..60020487061 --- /dev/null +++ b/spec/lib/gitlab/conflict/file_spec.rb @@ -0,0 +1,261 @@ +require 'spec_helper' + +describe Gitlab::Conflict::File, lib: true do + let(:project) { create(:project) } + let(:repository) { project.repository } + let(:rugged) { repository.rugged } + let(:their_commit) { rugged.branches['conflict-start'].target } + let(:our_commit) { rugged.branches['conflict-resolvable'].target } + let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) } + let(:index) { rugged.merge_commits(our_commit, their_commit) } + let(:conflict) { index.conflicts.last } + let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') } + let(:conflict_file) { Gitlab::Conflict::File.new(merge_file_result, conflict, merge_request: merge_request) } + + describe '#resolve_lines' do + let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact } + + context 'when resolving everything to the same side' do + let(:resolution_hash) { section_keys.map { |key| [key, 'head'] }.to_h } + let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) } + let(:expected_lines) { conflict_file.lines.reject { |line| line.type == 'old' } } + + it 'has the correct number of lines' do + expect(resolved_lines.length).to eq(expected_lines.length) + end + + it 'has content matching the chosen lines' do + expect(resolved_lines.map(&:text)).to eq(expected_lines.map(&:text)) + end + end + + context 'with mixed resolutions' do + let(:resolution_hash) do + section_keys.map.with_index { |key, i| [key, i.even? ? 'head' : 'origin'] }.to_h + end + + let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) } + + it 'has the correct number of lines' do + file_lines = conflict_file.lines.reject { |line| line.type == 'new' } + + expect(resolved_lines.length).to eq(file_lines.length) + end + + it 'returns a file containing only the chosen parts of the resolved sections' do + expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)). + to eq(['both', 'new', 'both', 'old', 'both', 'new', 'both']) + end + end + + it 'raises MissingResolution when passed a hash without resolutions for all sections' do + empty_hash = section_keys.map { |key| [key, nil] }.to_h + invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h + + expect { conflict_file.resolve_lines({}) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + + expect { conflict_file.resolve_lines(empty_hash) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + + expect { conflict_file.resolve_lines(invalid_hash) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + end + end + + describe '#highlight_lines!' do + def html_to_text(html) + CGI.unescapeHTML(ActionView::Base.full_sanitizer.sanitize(html)).delete("\n") + end + + it 'modifies the existing lines' do + expect { conflict_file.highlight_lines! }.to change { conflict_file.lines.map(&:instance_variables) } + end + + it 'is called implicitly when rich_text is accessed on a line' do + expect(conflict_file).to receive(:highlight_lines!).once.and_call_original + + conflict_file.lines.each(&:rich_text) + end + + it 'sets the rich_text of the lines matching the text content' do + conflict_file.lines.each do |line| + expect(line.text).to eq(html_to_text(line.rich_text)) + end + end + end + + describe '#sections' do + it 'only inserts match lines when there is a gap between sections' do + conflict_file.sections.each_with_index do |section, i| + previous_line_number = 0 + current_line_number = section[:lines].map(&:old_line).compact.min + + if i > 0 + previous_line_number = conflict_file.sections[i - 1][:lines].map(&:old_line).compact.last + end + + if current_line_number == previous_line_number + 1 + expect(section[:lines].first.type).not_to eq('match') + else + expect(section[:lines].first.type).to eq('match') + expect(section[:lines].first.text).to match(/\A@@ -#{current_line_number},\d+ \+\d+,\d+ @@ module Gitlab\Z/) + end + end + end + + it 'sets conflict to false for sections with only unchanged lines' do + conflict_file.sections.reject { |section| section[:conflict] }.each do |section| + without_match = section[:lines].reject { |line| line.type == 'match' } + + expect(without_match).to all(have_attributes(type: nil)) + end + end + + it 'only includes a maximum of CONTEXT_LINES (plus an optional match line) in context sections' do + conflict_file.sections.reject { |section| section[:conflict] }.each do |section| + without_match = section[:lines].reject { |line| line.type == 'match' } + + expect(without_match.length).to be <= Gitlab::Conflict::File::CONTEXT_LINES * 2 + end + end + + it 'sets conflict to true for sections with only changed lines' do + conflict_file.sections.select { |section| section[:conflict] }.each do |section| + section[:lines].each do |line| + expect(line.type).to be_in(['new', 'old']) + end + end + end + + it 'adds unique IDs to conflict sections, and not to other sections' do + section_ids = [] + + conflict_file.sections.each do |section| + if section[:conflict] + expect(section).to have_key(:id) + section_ids << section[:id] + else + expect(section).not_to have_key(:id) + end + end + + expect(section_ids.uniq).to eq(section_ids) + end + + context 'with an example file' do + let(:file) do + <<FILE + # Ensure there is no match line header here + def username_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb +def project_name_regexp + /\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/ +end + +def name_regexp + /\A[a-zA-Z0-9_\-\. ]*\z/ +======= +def project_name_regex + %r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z} +end + +def name_regex + %r{\A[a-zA-Z0-9_\-\. ]*\z} +>>>>>>> files/ruby/regex.rb +end + +# Some extra lines +# To force a match line +# To be created + +def path_regexp + default_regexp +end + +<<<<<<< files/ruby/regex.rb +def archive_formats_regexp + /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/ +======= +def archive_formats_regex + %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)} +>>>>>>> files/ruby/regex.rb +end + +def git_reference_regexp + # Valid git ref regexp, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?<!\.lock) (?# rule #1) + (?<![\/.]) (?# rule #6-7) + }x +end + +protected + +<<<<<<< files/ruby/regex.rb +def default_regexp + /\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/ +======= +def default_regex + %r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z} +>>>>>>> files/ruby/regex.rb +end +FILE + end + + let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) } + let(:sections) { conflict_file.sections } + + it 'sets the correct match line headers' do + expect(sections[0][:lines].first).to have_attributes(type: 'match', text: '@@ -3,14 +3,14 @@') + expect(sections[3][:lines].first).to have_attributes(type: 'match', text: '@@ -19,26 +19,26 @@ def path_regexp') + expect(sections[6][:lines].first).to have_attributes(type: 'match', text: '@@ -47,52 +47,52 @@ end') + end + + it 'does not add match lines where they are not needed' do + expect(sections[1][:lines].first.type).not_to eq('match') + expect(sections[2][:lines].first.type).not_to eq('match') + expect(sections[4][:lines].first.type).not_to eq('match') + expect(sections[5][:lines].first.type).not_to eq('match') + expect(sections[7][:lines].first.type).not_to eq('match') + end + + it 'creates context sections of the correct length' do + expect(sections[0][:lines].reject(&:type).length).to eq(3) + expect(sections[2][:lines].reject(&:type).length).to eq(3) + expect(sections[3][:lines].reject(&:type).length).to eq(3) + expect(sections[5][:lines].reject(&:type).length).to eq(3) + expect(sections[6][:lines].reject(&:type).length).to eq(3) + expect(sections[8][:lines].reject(&:type).length).to eq(1) + end + end + end + + describe '#as_json' do + it 'includes the blob path for the file' do + expect(conflict_file.as_json[:blob_path]). + to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb") + end + + it 'includes the blob icon for the file' do + expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o') + end + end +end diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb new file mode 100644 index 00000000000..16eb3766356 --- /dev/null +++ b/spec/lib/gitlab/conflict/parser_spec.rb @@ -0,0 +1,193 @@ +require 'spec_helper' + +describe Gitlab::Conflict::Parser, lib: true do + let(:parser) { Gitlab::Conflict::Parser.new } + + describe '#parse' do + def parse_text(text) + parser.parse(text, our_path: 'README.md', their_path: 'README.md') + end + + context 'when the file has valid conflicts' do + let(:text) do + <<CONFLICT +module Gitlab + module Regexp + extend self + + def username_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb + def project_name_regexp + /\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/ + end + + def name_regexp + /\A[a-zA-Z0-9_\-\. ]*\z/ +======= + def project_name_regex + %r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z} + end + + def name_regex + %r{\A[a-zA-Z0-9_\-\. ]*\z} +>>>>>>> files/ruby/regex.rb + end + + def path_regexp + default_regexp + end + +<<<<<<< files/ruby/regex.rb + def archive_formats_regexp + /(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/ +======= + def archive_formats_regex + %r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)} +>>>>>>> files/ruby/regex.rb + end + + def git_reference_regexp + # Valid git ref regexp, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?<!\.lock) (?# rule #1) + (?<![\/.]) (?# rule #6-7) + }x + end + + protected + +<<<<<<< files/ruby/regex.rb + def default_regexp + /\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/ +======= + def default_regex + %r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z} +>>>>>>> files/ruby/regex.rb + end + end +end +CONFLICT + end + + let(:lines) do + parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb') + end + + it 'sets our lines as new lines' do + expect(lines[8..13]).to all(have_attributes(type: 'new')) + expect(lines[26..27]).to all(have_attributes(type: 'new')) + expect(lines[56..57]).to all(have_attributes(type: 'new')) + end + + it 'sets their lines as old lines' do + expect(lines[14..19]).to all(have_attributes(type: 'old')) + expect(lines[28..29]).to all(have_attributes(type: 'old')) + expect(lines[58..59]).to all(have_attributes(type: 'old')) + end + + it 'sets non-conflicted lines as both' do + expect(lines[0..7]).to all(have_attributes(type: nil)) + expect(lines[20..25]).to all(have_attributes(type: nil)) + expect(lines[30..55]).to all(have_attributes(type: nil)) + expect(lines[60..62]).to all(have_attributes(type: nil)) + end + + it 'sets consecutive line numbers for index, old_pos, and new_pos' do + old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos) + new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos) + + expect(lines.map(&:index)).to eq(0.upto(62).to_a) + expect(old_line_numbers).to eq(1.upto(53).to_a) + expect(new_line_numbers).to eq(1.upto(53).to_a) + end + end + + context 'when the file contents include conflict delimiters' do + it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do + expect { parse_text('=======') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text('>>>>>>> README.md') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text('>>>>>>> some-other-path.md') }. + not_to raise_error + end + + it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do + start_text = "<<<<<<< README.md\n" + end_text = "\n=======\n>>>>>>> README.md" + + expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end + + it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do + start_text = "<<<<<<< README.md\n=======\n" + end_text = "\n>>>>>>> README.md" + + expect { parse_text(start_text + '=======' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end + + it 'raises MissingEndDelimiter when there is no end delimiter at the end' do + start_text = "<<<<<<< README.md\n=======\n" + + expect { parse_text(start_text) }. + to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter) + + expect { parse_text(start_text + '>>>>>>> some-other-path.md') }. + to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter) + end + end + + context 'other file types' do + it 'raises UnmergeableFile when lines is blank, indicating a binary file' do + expect { parse_text('') }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + + expect { parse_text(nil) }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + end + + it 'raises UnmergeableFile when the file is over 200 KB' do + expect { parse_text('a' * 204801) }. + to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) + end + + it 'raises UnsupportedEncoding when the file contains non-UTF-8 characters' do + expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }. + to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding) + end + end + end +end diff --git a/spec/lib/gitlab/build_data_builder_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb index 23ae5cfacc4..6c71e98066b 100644 --- a/spec/lib/gitlab/build_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/build_spec.rb @@ -1,11 +1,11 @@ require 'spec_helper' -describe 'Gitlab::BuildDataBuilder' do +describe Gitlab::DataBuilder::Build do let(:build) { create(:ci_build) } describe '.build' do let(:data) do - Gitlab::BuildDataBuilder.build(build) + described_class.build(build) end it { expect(data).to be_a(Hash) } diff --git a/spec/lib/gitlab/note_data_builder_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb index 3d6bcdfd873..9a4dec91e56 100644 --- a/spec/lib/gitlab/note_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' -describe 'Gitlab::NoteDataBuilder', lib: true do +describe Gitlab::DataBuilder::Note, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::NoteDataBuilder.build(note, user) } + let(:data) { described_class.build(note, user) } let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors before(:each) do diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb new file mode 100644 index 00000000000..a68f5943a6a --- /dev/null +++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::DataBuilder::Pipeline do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + status: 'success', + sha: project.commit.sha, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, pipeline: pipeline) } + + describe '.build' do + let(:data) { described_class.build(pipeline) } + let(:attributes) { data[:object_attributes] } + let(:build_data) { data[:builds].first } + let(:project_data) { data[:project] } + + it { expect(attributes).to be_a(Hash) } + it { expect(attributes[:ref]).to eq(pipeline.ref) } + it { expect(attributes[:sha]).to eq(pipeline.sha) } + it { expect(attributes[:tag]).to eq(pipeline.tag) } + it { expect(attributes[:id]).to eq(pipeline.id) } + it { expect(attributes[:status]).to eq(pipeline.status) } + + it { expect(build_data).to be_a(Hash) } + it { expect(build_data[:id]).to eq(build.id) } + it { expect(build_data[:status]).to eq(build.status) } + + it { expect(project_data).to eq(project.hook_attrs(backward: false)) } + end +end diff --git a/spec/lib/gitlab/push_data_builder_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index 6bd7393aaa7..a379f798a16 100644 --- a/spec/lib/gitlab/push_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::PushDataBuilder, lib: true do +describe Gitlab::DataBuilder::Push, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } @@ -8,13 +8,13 @@ describe Gitlab::PushDataBuilder, lib: true do let(:data) { described_class.build_sample(project, user) } it { expect(data).to be_a(Hash) } - it { expect(data[:before]).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } - it { expect(data[:after]).to eq('5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + it { expect(data[:before]).to eq('1b12f15a11fc6e62177bef08f47bc7b5ce50b141') } + it { expect(data[:after]).to eq('b83d6e391c22777fca1ed3012fce84f633d7fed0') } it { expect(data[:ref]).to eq('refs/heads/master') } it { expect(data[:commits].size).to eq(3) } it { expect(data[:total_commits_count]).to eq(3) } - it { expect(data[:commits].first[:added]).to eq(['gitlab-grack']) } - it { expect(data[:commits].first[:modified]).to eq(['.gitmodules']) } + it { expect(data[:commits].first[:added]).to eq(['bar/branch-test.txt']) } + it { expect(data[:commits].first[:modified]).to eq([]) } it { expect(data[:commits].first[:removed]).to eq([]) } include_examples 'project hook data with deprecateds' diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 4ec3f19e03f..7fd25b9e5bf 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -91,63 +91,80 @@ describe Gitlab::Database::MigrationHelpers, lib: true do describe '#add_column_with_default' do context 'outside of a transaction' do - before do - expect(model).to receive(:transaction_open?).and_return(false) + context 'when a column limit is not set' do + before do + expect(model).to receive(:transaction_open?).and_return(false) - expect(model).to receive(:transaction).and_yield + expect(model).to receive(:transaction).and_yield - expect(model).to receive(:add_column). - with(:projects, :foo, :integer, default: nil) + expect(model).to receive(:add_column). + with(:projects, :foo, :integer, default: nil) - expect(model).to receive(:change_column_default). - with(:projects, :foo, 10) - end + expect(model).to receive(:change_column_default). + with(:projects, :foo, 10) + end - it 'adds the column while allowing NULL values' do - expect(model).to receive(:update_column_in_batches). - with(:projects, :foo, 10) + it 'adds the column while allowing NULL values' do + expect(model).to receive(:update_column_in_batches). + with(:projects, :foo, 10) - expect(model).not_to receive(:change_column_null) + expect(model).not_to receive(:change_column_null) - model.add_column_with_default(:projects, :foo, :integer, - default: 10, - allow_null: true) - end + model.add_column_with_default(:projects, :foo, :integer, + default: 10, + allow_null: true) + end - it 'adds the column while not allowing NULL values' do - expect(model).to receive(:update_column_in_batches). - with(:projects, :foo, 10) + it 'adds the column while not allowing NULL values' do + expect(model).to receive(:update_column_in_batches). + with(:projects, :foo, 10) - expect(model).to receive(:change_column_null). - with(:projects, :foo, false) + expect(model).to receive(:change_column_null). + with(:projects, :foo, false) - model.add_column_with_default(:projects, :foo, :integer, default: 10) - end + model.add_column_with_default(:projects, :foo, :integer, default: 10) + end - it 'removes the added column whenever updating the rows fails' do - expect(model).to receive(:update_column_in_batches). - with(:projects, :foo, 10). - and_raise(RuntimeError) + it 'removes the added column whenever updating the rows fails' do + expect(model).to receive(:update_column_in_batches). + with(:projects, :foo, 10). + and_raise(RuntimeError) - expect(model).to receive(:remove_column). - with(:projects, :foo) + expect(model).to receive(:remove_column). + with(:projects, :foo) - expect do - model.add_column_with_default(:projects, :foo, :integer, default: 10) - end.to raise_error(RuntimeError) + expect do + model.add_column_with_default(:projects, :foo, :integer, default: 10) + end.to raise_error(RuntimeError) + end + + it 'removes the added column whenever changing a column NULL constraint fails' do + expect(model).to receive(:change_column_null). + with(:projects, :foo, false). + and_raise(RuntimeError) + + expect(model).to receive(:remove_column). + with(:projects, :foo) + + expect do + model.add_column_with_default(:projects, :foo, :integer, default: 10) + end.to raise_error(RuntimeError) + end end - it 'removes the added column whenever changing a column NULL constraint fails' do - expect(model).to receive(:change_column_null). - with(:projects, :foo, false). - and_raise(RuntimeError) + context 'when a column limit is set' do + it 'adds the column with a limit' do + allow(model).to receive(:transaction_open?).and_return(false) + allow(model).to receive(:transaction).and_yield + allow(model).to receive(:update_column_in_batches).with(:projects, :foo, 10) + allow(model).to receive(:change_column_null).with(:projects, :foo, false) + allow(model).to receive(:change_column_default).with(:projects, :foo, 10) - expect(model).to receive(:remove_column). - with(:projects, :foo) + expect(model).to receive(:add_column). + with(:projects, :foo, :integer, default: nil, limit: 8) - expect do - model.add_column_with_default(:projects, :foo, :integer, default: 10) - end.to raise_error(RuntimeError) + model.add_column_with_default(:projects, :foo, :integer, default: 10, limit: 8) + end end end diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb index 10537bea008..6e8fff6f516 100644 --- a/spec/lib/gitlab/diff/position_spec.rb +++ b/spec/lib/gitlab/diff/position_spec.rb @@ -339,6 +339,48 @@ describe Gitlab::Diff::Position, lib: true do end end + describe "position for a file in the initial commit" do + let(:commit) { project.commit("1a0b36b3cdad1d2ee32457c102a8c0b7056fa863") } + + subject do + described_class.new( + old_path: "README.md", + new_path: "README.md", + old_line: nil, + new_line: 1, + diff_refs: commit.diff_refs + ) + end + + describe "#diff_file" do + it "returns the correct diff file" do + diff_file = subject.diff_file(project.repository) + + expect(diff_file.new_file).to be true + expect(diff_file.new_path).to eq(subject.new_path) + expect(diff_file.diff_refs).to eq(subject.diff_refs) + end + end + + describe "#diff_line" do + it "returns the correct diff line" do + diff_line = subject.diff_line(project.repository) + + expect(diff_line.added?).to be true + expect(diff_line.new_line).to eq(subject.new_line) + expect(diff_line.text).to eq("+testme") + end + end + + describe "#line_code" do + it "returns the correct line code" do + line_code = Gitlab::Diff::LineCode.generate(subject.file_path, subject.new_line, 0) + + expect(subject.line_code(project.repository)).to eq(line_code) + end + end + end + describe "#to_json" do let(:hash) do { diff --git a/spec/lib/gitlab/downtime_check/message_spec.rb b/spec/lib/gitlab/downtime_check/message_spec.rb index 93094cda776..a5a398abf78 100644 --- a/spec/lib/gitlab/downtime_check/message_spec.rb +++ b/spec/lib/gitlab/downtime_check/message_spec.rb @@ -5,13 +5,35 @@ describe Gitlab::DowntimeCheck::Message do it 'returns an ANSI formatted String for an offline migration' do message = described_class.new('foo.rb', true, 'hello') - expect(message.to_s).to eq("[\e[32moffline\e[0m]: foo.rb: hello") + expect(message.to_s).to eq("[\e[31moffline\e[0m]: foo.rb:\n\nhello\n\n") end it 'returns an ANSI formatted String for an online migration' do message = described_class.new('foo.rb') - expect(message.to_s).to eq("[\e[31monline\e[0m]: foo.rb") + expect(message.to_s).to eq("[\e[32monline\e[0m]: foo.rb") + end + end + + describe '#reason?' do + it 'returns false when no reason is specified' do + message = described_class.new('foo.rb') + + expect(message.reason?).to eq(false) + end + + it 'returns true when a reason is specified' do + message = described_class.new('foo.rb', true, 'hello') + + expect(message.reason?).to eq(true) + end + end + + describe '#reason' do + it 'strips excessive whitespace from the returned String' do + message = described_class.new('foo.rb', true, " hello\n world\n\n foo") + + expect(message.reason).to eq("hello\nworld\n\nfoo") end end end diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb index e1153154778..a5cc7b02936 100644 --- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require_relative '../email_shared_blocks' -describe Gitlab::Email::Handler::CreateIssueHandler, lib: true do +xdescribe Gitlab::Email::Handler::CreateIssueHandler, lib: true do include_context :email_shared_context it_behaves_like :email_shared_examples diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index a2119b0dadf..4909fed6b77 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -60,6 +60,67 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do it "raises an InvalidNoteError" do expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) end + + context 'because the note was commands only' do + let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") } + + context 'and current user cannot update noteable' do + it 'raises a CommandsOnlyNoteError' do + expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) + end + end + + context 'and current user can update noteable' do + before do + project.team << [user, :developer] + end + + it 'does not raise an error' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the 'close' event + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + + expect(noteable.reload).to be_closed + expect(noteable.due_date).to eq(Date.tomorrow) + expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy + end + end + end + end + + context 'when the note contains slash commands' do + let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") } + + context 'and current user cannot update noteable' do + it 'post a note and does not update the noteable' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the new note + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + + expect(noteable.reload).to be_open + expect(noteable.due_date).to be_nil + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + end + end + + context 'and current user can update noteable' do + before do + project.team << [user, :developer] + end + + it 'post a note and updates the noteable' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the new note, one for the 'close' event + expect { receiver.execute }.to change { noteable.notes.count }.by(2) + + expect(noteable.reload).to be_closed + expect(noteable.due_date).to eq(Date.tomorrow) + expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy + end + end end context "when the reply is blank" do diff --git a/spec/lib/gitlab/git/hook_spec.rb b/spec/lib/gitlab/git/hook_spec.rb index a15aa173fbd..d1f947b6850 100644 --- a/spec/lib/gitlab/git/hook_spec.rb +++ b/spec/lib/gitlab/git/hook_spec.rb @@ -25,7 +25,6 @@ describe Gitlab::Git::Hook, lib: true do end ['pre-receive', 'post-receive', 'update'].each do |hook_name| - context "when triggering a #{hook_name} hook" do context "when the hook is successful" do it "returns success with no errors" do diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 8447305a316..de68e32e5b4 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -1,10 +1,17 @@ require 'spec_helper' describe Gitlab::GitAccess, lib: true do - let(:access) { Gitlab::GitAccess.new(actor, project, 'web') } + let(:access) { Gitlab::GitAccess.new(actor, project, 'web', authentication_abilities: authentication_abilities) } let(:project) { create(:project) } let(:user) { create(:user) } let(:actor) { user } + let(:authentication_abilities) do + [ + :read_project, + :download_code, + :push_code + ] + end describe '#check with single protocols allowed' do def disable_protocol(protocol) @@ -15,36 +22,36 @@ describe Gitlab::GitAccess, lib: true do context 'ssh disabled' do before do disable_protocol('ssh') - @acc = Gitlab::GitAccess.new(actor, project, 'ssh') + @acc = Gitlab::GitAccess.new(actor, project, 'ssh', authentication_abilities: authentication_abilities) end it 'blocks ssh git push' do - expect(@acc.check('git-receive-pack').allowed?).to be_falsey + expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey end it 'blocks ssh git pull' do - expect(@acc.check('git-upload-pack').allowed?).to be_falsey + expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey end end context 'http disabled' do before do disable_protocol('http') - @acc = Gitlab::GitAccess.new(actor, project, 'http') + @acc = Gitlab::GitAccess.new(actor, project, 'http', authentication_abilities: authentication_abilities) end it 'blocks http push' do - expect(@acc.check('git-receive-pack').allowed?).to be_falsey + expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey end it 'blocks http git pull' do - expect(@acc.check('git-upload-pack').allowed?).to be_falsey + expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey end end end describe 'download_access_check' do - subject { access.check('git-upload-pack') } + subject { access.check('git-upload-pack', '_any') } describe 'master permissions' do before { project.team << [user, :master] } @@ -111,6 +118,36 @@ describe Gitlab::GitAccess, lib: true do end end end + + describe 'build authentication_abilities permissions' do + let(:authentication_abilities) { build_authentication_abilities } + + describe 'reporter user' do + before { project.team << [user, :reporter] } + + context 'pull code' do + it { expect(subject).to be_allowed } + end + end + + describe 'admin user' do + let(:user) { create(:admin) } + + context 'when member of the project' do + before { project.team << [user, :reporter] } + + context 'pull code' do + it { expect(subject).to be_allowed } + end + end + + context 'when is not member of the project' do + context 'pull code' do + it { expect(subject).not_to be_allowed } + end + end + end + end end describe 'push_access_check' do @@ -283,38 +320,71 @@ describe Gitlab::GitAccess, lib: true do end end - describe 'deploy key permissions' do - let(:key) { create(:deploy_key) } - let(:actor) { key } + shared_examples 'can not push code' do + subject { access.check('git-receive-pack', '_any') } + + context 'when project is authorized' do + before { authorize } - context 'push code' do - subject { access.check('git-receive-pack') } + it { expect(subject).not_to be_allowed } + end - context 'when project is authorized' do - before { key.projects << project } + context 'when unauthorized' do + context 'to public project' do + let(:project) { create(:project, :public) } it { expect(subject).not_to be_allowed } end - context 'when unauthorized' do - context 'to public project' do - let(:project) { create(:project, :public) } + context 'to internal project' do + let(:project) { create(:project, :internal) } - it { expect(subject).not_to be_allowed } - end + it { expect(subject).not_to be_allowed } + end - context 'to internal project' do - let(:project) { create(:project, :internal) } + context 'to private project' do + let(:project) { create(:project) } - it { expect(subject).not_to be_allowed } - end + it { expect(subject).not_to be_allowed } + end + end + end - context 'to private project' do - let(:project) { create(:project, :internal) } + describe 'build authentication abilities' do + let(:authentication_abilities) { build_authentication_abilities } - it { expect(subject).not_to be_allowed } - end + it_behaves_like 'can not push code' do + def authorize + project.team << [user, :reporter] end end end + + describe 'deploy key permissions' do + let(:key) { create(:deploy_key) } + let(:actor) { key } + + it_behaves_like 'can not push code' do + def authorize + key.projects << project + end + end + end + + private + + def build_authentication_abilities + [ + :read_project, + :build_download_code + ] + end + + def full_authentication_abilities + [ + :read_project, + :download_code, + :push_code + ] + end end diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb index 4244b807d41..576cda595bb 100644 --- a/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/spec/lib/gitlab/git_access_wiki_spec.rb @@ -1,9 +1,16 @@ require 'spec_helper' describe Gitlab::GitAccessWiki, lib: true do - let(:access) { Gitlab::GitAccessWiki.new(user, project, 'web') } + let(:access) { Gitlab::GitAccessWiki.new(user, project, 'web', authentication_abilities: authentication_abilities) } let(:project) { create(:project) } let(:user) { create(:user) } + let(:authentication_abilities) do + [ + :read_project, + :download_code, + :push_code + ] + end describe 'push_allowed?' do before do diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb new file mode 100644 index 00000000000..219198eff60 --- /dev/null +++ b/spec/lib/gitlab/git_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe Gitlab::Git, lib: true do + let(:committer_email) { FFaker::Internet.email } + + # I have to remove periods from the end of the name + # This happened when the user's name had a suffix (i.e. "Sr.") + # This seems to be what git does under the hood. For example, this commit: + # + # $ git commit --author='Foo Sr. <foo@example.com>' -m 'Where's my trailing period?' + # + # results in this: + # + # $ git show --pretty + # ... + # Author: Foo Sr <foo@example.com> + # ... + let(:committer_name) { FFaker::Name.name.chomp("\.") } + + describe 'committer_hash' do + it "returns a hash containing the given email and name" do + committer_hash = Gitlab::Git::committer_hash(email: committer_email, name: committer_name) + + expect(committer_hash[:email]).to eq(committer_email) + expect(committer_hash[:name]).to eq(committer_name) + expect(committer_hash[:time]).to be_a(Time) + end + + context 'when email is nil' do + it "returns nil" do + committer_hash = Gitlab::Git::committer_hash(email: nil, name: committer_name) + + expect(committer_hash).to be_nil + end + end + + context 'when name is nil' do + it "returns nil" do + committer_hash = Gitlab::Git::committer_hash(email: committer_email, name: nil) + + expect(committer_hash).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/github_import/branch_formatter_spec.rb b/spec/lib/gitlab/github_import/branch_formatter_spec.rb index fc9d5204148..e5300dbba1e 100644 --- a/spec/lib/gitlab/github_import/branch_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/branch_formatter_spec.rb @@ -32,20 +32,6 @@ describe Gitlab::GithubImport::BranchFormatter, lib: true do end end - describe '#name' do - it 'returns raw ref when branch exists' do - branch = described_class.new(project, double(raw)) - - expect(branch.name).to eq 'feature' - end - - it 'returns formatted ref when branch does not exist' do - branch = described_class.new(project, double(raw.merge(ref: 'removed-branch', sha: '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b'))) - - expect(branch.name).to eq 'removed-branch-2e5d3239' - end - end - describe '#repo' do it 'returns raw repo' do branch = described_class.new(project, double(raw)) diff --git a/spec/lib/gitlab/github_import/client_spec.rb b/spec/lib/gitlab/github_import/client_spec.rb index 613c47d55f1..e829b936343 100644 --- a/spec/lib/gitlab/github_import/client_spec.rb +++ b/spec/lib/gitlab/github_import/client_spec.rb @@ -66,6 +66,6 @@ describe Gitlab::GithubImport::Client, lib: true do stub_request(:get, /api.github.com/) allow(client.api).to receive(:rate_limit!).and_raise(Octokit::NotFound) - expect { client.issues }.not_to raise_error + expect { client.issues {} }.not_to raise_error end end diff --git a/spec/lib/gitlab/github_import/comment_formatter_spec.rb b/spec/lib/gitlab/github_import/comment_formatter_spec.rb index 9ae02a6c45f..c520a9c53ad 100644 --- a/spec/lib/gitlab/github_import/comment_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/comment_formatter_spec.rb @@ -73,6 +73,12 @@ describe Gitlab::GithubImport::CommentFormatter, lib: true do gl_user = create(:omniauth_user, extern_uid: octocat.id, provider: 'github') expect(comment.attributes.fetch(:author_id)).to eq gl_user.id end + + it 'returns note without created at tag line' do + create(:omniauth_user, extern_uid: octocat.id, provider: 'github') + + expect(comment.attributes.fetch(:note)).to eq("I'm having a problem with this.") + end end end end diff --git a/spec/lib/gitlab/github_import/hook_formatter_spec.rb b/spec/lib/gitlab/github_import/hook_formatter_spec.rb deleted file mode 100644 index 110ba428258..00000000000 --- a/spec/lib/gitlab/github_import/hook_formatter_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -require 'spec_helper' - -describe Gitlab::GithubImport::HookFormatter, lib: true do - describe '#id' do - it 'returns raw id' do - raw = double(id: 100000) - formatter = described_class.new(raw) - expect(formatter.id).to eq 100000 - end - end - - describe '#name' do - it 'returns raw id' do - raw = double(name: 'web') - formatter = described_class.new(raw) - expect(formatter.name).to eq 'web' - end - end - - describe '#config' do - it 'returns raw config.attrs' do - raw = double(config: double(attrs: { url: 'http://something.com/webhook' })) - formatter = described_class.new(raw) - expect(formatter.config).to eq({ url: 'http://something.com/webhook' }) - end - end - - describe '#valid?' do - it 'returns true when events contains the wildcard event' do - raw = double(events: ['*', 'commit_comment'], active: true) - formatter = described_class.new(raw) - expect(formatter.valid?).to eq true - end - - it 'returns true when events contains the create event' do - raw = double(events: ['create', 'commit_comment'], active: true) - formatter = described_class.new(raw) - expect(formatter.valid?).to eq true - end - - it 'returns true when events contains delete event' do - raw = double(events: ['delete', 'commit_comment'], active: true) - formatter = described_class.new(raw) - expect(formatter.valid?).to eq true - end - - it 'returns true when events contains pull_request event' do - raw = double(events: ['pull_request', 'commit_comment'], active: true) - formatter = described_class.new(raw) - expect(formatter.valid?).to eq true - end - - it 'returns false when events does not contains branch related events' do - raw = double(events: ['member', 'commit_comment'], active: true) - formatter = described_class.new(raw) - expect(formatter.valid?).to eq false - end - - it 'returns false when hook is not active' do - raw = double(events: ['pull_request', 'commit_comment'], active: false) - formatter = described_class.new(raw) - expect(formatter.valid?).to eq false - end - end -end diff --git a/spec/lib/gitlab/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb new file mode 100644 index 00000000000..8854c8431b5 --- /dev/null +++ b/spec/lib/gitlab/github_import/importer_spec.rb @@ -0,0 +1,169 @@ +require 'spec_helper' + +describe Gitlab::GithubImport::Importer, lib: true do + describe '#execute' do + context 'when an error occurs' do + let(:project) { create(:project, import_url: 'https://github.com/octocat/Hello-World.git', wiki_access_level: ProjectFeature::DISABLED) } + let(:octocat) { double(id: 123456, login: 'octocat') } + let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') } + let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') } + let(:repository) { double(id: 1, fork: false) } + let(:source_sha) { create(:commit, project: project).id } + let(:source_branch) { double(ref: 'feature', repo: repository, sha: source_sha) } + let(:target_sha) { create(:commit, project: project, git_commit: RepoHelpers.another_sample_commit).id } + let(:target_branch) { double(ref: 'master', repo: repository, sha: target_sha) } + + let(:label1) do + double( + name: 'Bug', + color: 'ff0000', + url: 'https://api.github.com/repos/octocat/Hello-World/labels/bug' + ) + end + + let(:label2) do + double( + name: nil, + color: 'ff0000', + url: 'https://api.github.com/repos/octocat/Hello-World/labels/bug' + ) + end + + let(:milestone) do + double( + number: 1347, + state: 'open', + title: '1.0', + description: 'Version 1.0', + due_on: nil, + created_at: created_at, + updated_at: updated_at, + closed_at: nil, + url: 'https://api.github.com/repos/octocat/Hello-World/milestones/1' + ) + end + + let(:issue1) do + double( + number: 1347, + milestone: nil, + state: 'open', + title: 'Found a bug', + body: "I'm having a problem with this.", + assignee: nil, + user: octocat, + comments: 0, + pull_request: nil, + created_at: created_at, + updated_at: updated_at, + closed_at: nil, + url: 'https://api.github.com/repos/octocat/Hello-World/issues/1347', + labels: [double(name: 'Label #1')], + ) + end + + let(:issue2) do + double( + number: 1348, + milestone: nil, + state: 'open', + title: nil, + body: "I'm having a problem with this.", + assignee: nil, + user: octocat, + comments: 0, + pull_request: nil, + created_at: created_at, + updated_at: updated_at, + closed_at: nil, + url: 'https://api.github.com/repos/octocat/Hello-World/issues/1348', + labels: [double(name: 'Label #2')], + ) + end + + let(:pull_request) do + double( + number: 1347, + milestone: nil, + state: 'open', + title: 'New feature', + body: 'Please pull these awesome changes', + head: source_branch, + base: target_branch, + assignee: nil, + user: octocat, + created_at: created_at, + updated_at: updated_at, + closed_at: nil, + merged_at: nil, + url: 'https://api.github.com/repos/octocat/Hello-World/pulls/1347', + labels: [double(name: 'Label #3')], + ) + end + + let(:release1) do + double( + tag_name: 'v1.0.0', + name: 'First release', + body: 'Release v1.0.0', + draft: false, + created_at: created_at, + updated_at: updated_at, + url: 'https://api.github.com/repos/octocat/Hello-World/releases/1' + ) + end + + let(:release2) do + double( + tag_name: 'v2.0.0', + name: 'Second release', + body: nil, + draft: false, + created_at: created_at, + updated_at: updated_at, + url: 'https://api.github.com/repos/octocat/Hello-World/releases/2' + ) + end + + before do + allow(project).to receive(:import_data).and_return(double.as_null_object) + allow_any_instance_of(Octokit::Client).to receive(:rate_limit!).and_raise(Octokit::NotFound) + allow_any_instance_of(Octokit::Client).to receive(:labels).and_return([label1, label2]) + allow_any_instance_of(Octokit::Client).to receive(:milestones).and_return([milestone, milestone]) + allow_any_instance_of(Octokit::Client).to receive(:issues).and_return([issue1, issue2]) + allow_any_instance_of(Octokit::Client).to receive(:pull_requests).and_return([pull_request, pull_request]) + allow_any_instance_of(Octokit::Client).to receive(:issues_comments).and_return([]) + allow_any_instance_of(Octokit::Client).to receive(:pull_requests_comments).and_return([]) + allow_any_instance_of(Octokit::Client).to receive(:last_response).and_return(double(rels: { next: nil })) + allow_any_instance_of(Octokit::Client).to receive(:releases).and_return([release1, release2]) + allow_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_raise(Gitlab::Shell::Error) + end + + it 'returns true' do + expect(described_class.new(project).execute).to eq true + end + + it 'does not raise an error' do + expect { described_class.new(project).execute }.not_to raise_error + end + + it 'stores error messages' do + error = { + message: 'The remote data could not be fully imported.', + errors: [ + { type: :label, url: "https://api.github.com/repos/octocat/Hello-World/labels/bug", errors: "Validation failed: Title can't be blank, Title is invalid" }, + { type: :milestone, url: "https://api.github.com/repos/octocat/Hello-World/milestones/1", errors: "Validation failed: Title has already been taken" }, + { type: :issue, url: "https://api.github.com/repos/octocat/Hello-World/issues/1348", errors: "Validation failed: Title can't be blank, Title is too short (minimum is 0 characters)" }, + { type: :pull_request, url: "https://api.github.com/repos/octocat/Hello-World/pulls/1347", errors: "Validation failed: Validate branches Cannot Create: This merge request already exists: [\"New feature\"]" }, + { type: :wiki, errors: "Gitlab::Shell::Error" }, + { type: :release, url: 'https://api.github.com/repos/octocat/Hello-World/releases/2', errors: "Validation failed: Description can't be blank" } + ] + } + + described_class.new(project).execute + + expect(project.import_error).to eq error.to_json + end + end + end +end diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb index 0e7ffbe9b8e..c2f1f6b91a1 100644 --- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb @@ -48,8 +48,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do end context 'when issue is closed' do - let(:closed_at) { DateTime.strptime('2011-01-28T19:01:12Z') } - let(:raw_data) { double(base_data.merge(state: 'closed', closed_at: closed_at)) } + let(:raw_data) { double(base_data.merge(state: 'closed')) } it 'returns formatted attributes' do expected = { @@ -62,7 +61,7 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do author_id: project.creator_id, assignee_id: nil, created_at: created_at, - updated_at: closed_at + updated_at: updated_at } expect(issue.attributes).to eq(expected) @@ -110,6 +109,12 @@ describe Gitlab::GithubImport::IssueFormatter, lib: true do expect(issue.attributes.fetch(:author_id)).to eq gl_user.id end + + it 'returns description without created at tag line' do + create(:omniauth_user, extern_uid: octocat.id, provider: 'github') + + expect(issue.attributes.fetch(:description)).to eq("I'm having a problem with this.") + end end end diff --git a/spec/lib/gitlab/github_import/label_formatter_spec.rb b/spec/lib/gitlab/github_import/label_formatter_spec.rb index 87593e32db0..8098754d735 100644 --- a/spec/lib/gitlab/github_import/label_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/label_formatter_spec.rb @@ -1,18 +1,34 @@ require 'spec_helper' describe Gitlab::GithubImport::LabelFormatter, lib: true do - describe '#attributes' do - it 'returns formatted attributes' do - project = create(:project) - raw = double(name: 'improvements', color: 'e6e6e6') + let(:project) { create(:project) } + let(:raw) { double(name: 'improvements', color: 'e6e6e6') } - formatter = described_class.new(project, raw) + subject { described_class.new(project, raw) } - expect(formatter.attributes).to eq({ + describe '#attributes' do + it 'returns formatted attributes' do + expect(subject.attributes).to eq({ project: project, title: 'improvements', color: '#e6e6e6' }) end end + + describe '#create!' do + context 'when label does not exist' do + it 'creates a new label' do + expect { subject.create! }.to change(Label, :count).by(1) + end + end + + context 'when label exists' do + it 'does not create a new label' do + project.labels.create(name: raw.name) + + expect { subject.create! }.not_to change(Label, :count) + end + end + end end diff --git a/spec/lib/gitlab/github_import/milestone_formatter_spec.rb b/spec/lib/gitlab/github_import/milestone_formatter_spec.rb index 5a421e50581..09337c99a07 100644 --- a/spec/lib/gitlab/github_import/milestone_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/milestone_formatter_spec.rb @@ -40,8 +40,7 @@ describe Gitlab::GithubImport::MilestoneFormatter, lib: true do end context 'when milestone is closed' do - let(:closed_at) { DateTime.strptime('2011-01-28T19:01:12Z') } - let(:raw_data) { double(base_data.merge(state: 'closed', closed_at: closed_at)) } + let(:raw_data) { double(base_data.merge(state: 'closed')) } it 'returns formatted attributes' do expected = { @@ -52,7 +51,7 @@ describe Gitlab::GithubImport::MilestoneFormatter, lib: true do state: 'closed', due_date: nil, created_at: created_at, - updated_at: closed_at + updated_at: updated_at } expect(formatter.attributes).to eq(expected) diff --git a/spec/lib/gitlab/github_import/project_creator_spec.rb b/spec/lib/gitlab/github_import/project_creator_spec.rb index 0f363b8b0aa..a73b1f4ff5d 100644 --- a/spec/lib/gitlab/github_import/project_creator_spec.rb +++ b/spec/lib/gitlab/github_import/project_creator_spec.rb @@ -2,33 +2,79 @@ require 'spec_helper' describe Gitlab::GithubImport::ProjectCreator, lib: true do let(:user) { create(:user) } + let(:namespace) { create(:group, owner: user) } + let(:repo) do OpenStruct.new( login: 'vim', name: 'vim', - private: true, full_name: 'asd/vim', - clone_url: "https://gitlab.com/asd/vim.git", - owner: OpenStruct.new(login: "john") + clone_url: 'https://gitlab.com/asd/vim.git' ) end - let(:namespace) { create(:group, owner: user) } - let(:token) { "asdffg" } - let(:access_params) { { github_access_token: token } } + + subject(:service) { described_class.new(repo, repo.name, namespace, user, github_access_token: 'asdffg') } before do namespace.add_owner(user) + allow_any_instance_of(Project).to receive(:add_import_job) end - it 'creates project' do - allow_any_instance_of(Project).to receive(:add_import_job) + describe '#execute' do + it 'creates a project' do + expect { service.execute }.to change(Project, :count).by(1) + end + + it 'handle GitHub credentials' do + project = service.execute + + expect(project.import_url).to eq('https://asdffg@gitlab.com/asd/vim.git') + expect(project.safe_import_url).to eq('https://*****@gitlab.com/asd/vim.git') + expect(project.import_data.credentials).to eq(user: 'asdffg', password: nil) + end + + context 'when GitHub project is private' do + it 'sets project visibility to private' do + repo.private = true + + project = service.execute + + expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + end + + context 'when GitHub project is public' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:default_project_visibility).and_return(Gitlab::VisibilityLevel::INTERNAL) + end + + it 'sets project visibility to the default project visibility' do + repo.private = false + + project = service.execute + + expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) + end + end + + context 'when GitHub project has wiki' do + it 'does not create the wiki repository' do + allow(repo).to receive(:has_wiki?).and_return(true) + + project = service.execute + + expect(project.wiki.repository_exists?).to eq false + end + end + + context 'when GitHub project does not have wiki' do + it 'creates the wiki repository' do + allow(repo).to receive(:has_wiki?).and_return(false) - project_creator = Gitlab::GithubImport::ProjectCreator.new(repo, namespace, user, access_params) - project = project_creator.execute + project = service.execute - expect(project.import_url).to eq("https://asdffg@gitlab.com/asd/vim.git") - expect(project.safe_import_url).to eq("https://*****@gitlab.com/asd/vim.git") - expect(project.import_data.credentials).to eq(user: "asdffg", password: nil) - expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + expect(project.wiki.repository_exists?).to eq true + end + end end end diff --git a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb index 79931ecd134..302f0fc0623 100644 --- a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb @@ -9,6 +9,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do let(:source_branch) { double(ref: 'feature', repo: source_repo, sha: source_sha) } let(:target_repo) { repository } let(:target_branch) { double(ref: 'master', repo: target_repo, sha: target_sha) } + let(:removed_branch) { double(ref: 'removed-branch', repo: source_repo, sha: '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b') } let(:octocat) { double(id: 123456, login: 'octocat') } let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') } let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') } @@ -26,7 +27,8 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do created_at: created_at, updated_at: updated_at, closed_at: nil, - merged_at: nil + merged_at: nil, + url: 'https://api.github.com/repos/octocat/Hello-World/pulls/1347' } end @@ -60,8 +62,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do end context 'when pull request is closed' do - let(:closed_at) { DateTime.strptime('2011-01-28T19:01:12Z') } - let(:raw_data) { double(base_data.merge(state: 'closed', closed_at: closed_at)) } + let(:raw_data) { double(base_data.merge(state: 'closed')) } it 'returns formatted attributes' do expected = { @@ -79,7 +80,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do author_id: project.creator_id, assignee_id: nil, created_at: created_at, - updated_at: closed_at + updated_at: updated_at } expect(pull_request.attributes).to eq(expected) @@ -106,7 +107,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do author_id: project.creator_id, assignee_id: nil, created_at: created_at, - updated_at: merged_at + updated_at: updated_at } expect(pull_request.attributes).to eq(expected) @@ -139,6 +140,12 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do expect(pull_request.attributes.fetch(:author_id)).to eq gl_user.id end + + it 'returns description without created at tag line' do + create(:omniauth_user, extern_uid: octocat.id, provider: 'github') + + expect(pull_request.attributes.fetch(:description)).to eq('Please pull these awesome changes') + end end context 'when it has a milestone' do @@ -165,6 +172,42 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do end end + describe '#source_branch_name' do + context 'when source branch exists' do + let(:raw_data) { double(base_data) } + + it 'returns branch ref' do + expect(pull_request.source_branch_name).to eq 'feature' + end + end + + context 'when source branch does not exist' do + let(:raw_data) { double(base_data.merge(head: removed_branch)) } + + it 'prefixes branch name with pull request number' do + expect(pull_request.source_branch_name).to eq 'pull/1347/removed-branch' + end + end + end + + describe '#target_branch_name' do + context 'when source branch exists' do + let(:raw_data) { double(base_data) } + + it 'returns branch ref' do + expect(pull_request.target_branch_name).to eq 'master' + end + end + + context 'when target branch does not exist' do + let(:raw_data) { double(base_data.merge(base: removed_branch)) } + + it 'prefixes branch name with pull request number' do + expect(pull_request.target_branch_name).to eq 'pull/1347/removed-branch' + end + end + end + describe '#valid?' do context 'when source, and target repos are not a fork' do let(:raw_data) { double(base_data) } @@ -178,8 +221,8 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do let(:source_repo) { double(id: 2) } let(:raw_data) { double(base_data) } - it 'returns false' do - expect(pull_request.valid?).to eq false + it 'returns true' do + expect(pull_request.valid?).to eq true end end @@ -187,9 +230,17 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do let(:target_repo) { double(id: 2) } let(:raw_data) { double(base_data) } - it 'returns false' do - expect(pull_request.valid?).to eq false + it 'returns true' do + expect(pull_request.valid?).to eq true end end end + + describe '#url' do + let(:raw_data) { double(base_data) } + + it 'return raw url' do + expect(pull_request.url).to eq 'https://api.github.com/repos/octocat/Hello-World/pulls/1347' + end + end end diff --git a/spec/lib/gitlab/github_import/release_formatter_spec.rb b/spec/lib/gitlab/github_import/release_formatter_spec.rb new file mode 100644 index 00000000000..793128c6ab9 --- /dev/null +++ b/spec/lib/gitlab/github_import/release_formatter_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Gitlab::GithubImport::ReleaseFormatter, lib: true do + let!(:project) { create(:project, namespace: create(:namespace, path: 'octocat')) } + let(:octocat) { double(id: 123456, login: 'octocat') } + let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') } + + let(:base_data) do + { + tag_name: 'v1.0.0', + name: 'First release', + draft: false, + created_at: created_at, + published_at: created_at, + body: 'Release v1.0.0' + } + end + + subject(:release) { described_class.new(project, raw_data) } + + describe '#attributes' do + let(:raw_data) { double(base_data) } + + it 'returns formatted attributes' do + expected = { + project: project, + tag: 'v1.0.0', + description: 'Release v1.0.0', + created_at: created_at, + updated_at: created_at + } + + expect(release.attributes).to eq(expected) + end + end + + describe '#valid' do + context 'when release is not a draft' do + let(:raw_data) { double(base_data) } + + it 'returns true' do + expect(release.valid?).to eq true + end + end + + context 'when release is draft' do + let(:raw_data) { double(base_data.merge(draft: true)) } + + it 'returns false' do + expect(release.valid?).to eq false + end + end + end +end diff --git a/spec/lib/gitlab/gitlab_import/importer_spec.rb b/spec/lib/gitlab/gitlab_import/importer_spec.rb index d3f1deb3837..9b499b593d3 100644 --- a/spec/lib/gitlab/gitlab_import/importer_spec.rb +++ b/spec/lib/gitlab/gitlab_import/importer_spec.rb @@ -13,6 +13,7 @@ describe Gitlab::GitlabImport::Importer, lib: true do 'title' => 'Issue', 'description' => 'Lorem ipsum', 'state' => 'opened', + 'confidential' => true, 'author' => { 'id' => 283999, 'name' => 'John Doe' @@ -34,6 +35,7 @@ describe Gitlab::GitlabImport::Importer, lib: true do title: 'Issue', description: "*Created by: John Doe*\n\nLorem ipsum", state: 'opened', + confidential: true, author_id: project.creator_id } diff --git a/spec/lib/gitlab/gitorious_import/project_creator_spec.rb b/spec/lib/gitlab/gitorious_import/project_creator_spec.rb deleted file mode 100644 index 946712ca38e..00000000000 --- a/spec/lib/gitlab/gitorious_import/project_creator_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'spec_helper' - -describe Gitlab::GitoriousImport::ProjectCreator, lib: true do - let(:user) { create(:user) } - let(:repo) { Gitlab::GitoriousImport::Repository.new('foo/bar-baz-qux') } - let(:namespace){ create(:group, owner: user) } - - before do - namespace.add_owner(user) - end - - it 'creates project' do - allow_any_instance_of(Project).to receive(:add_import_job) - - project_creator = Gitlab::GitoriousImport::ProjectCreator.new(repo, namespace, user) - project = project_creator.execute - - expect(project.name).to eq("Bar Baz Qux") - expect(project.path).to eq("bar-baz-qux") - expect(project.namespace).to eq(namespace) - expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) - expect(project.import_type).to eq("gitorious") - expect(project.import_source).to eq("foo/bar-baz-qux") - expect(project.import_url).to eq("https://gitorious.org/foo/bar-baz-qux.git") - end -end diff --git a/spec/lib/gitlab/identifier_spec.rb b/spec/lib/gitlab/identifier_spec.rb new file mode 100644 index 00000000000..47d6f1007d1 --- /dev/null +++ b/spec/lib/gitlab/identifier_spec.rb @@ -0,0 +1,123 @@ +require 'spec_helper' + +describe Gitlab::Identifier do + let(:identifier) do + Class.new { include Gitlab::Identifier }.new + end + + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:key) { create(:key, user: user) } + + describe '#identify' do + context 'without an identifier' do + it 'identifies the user using a commit' do + expect(identifier).to receive(:identify_using_commit). + with(project, '123') + + identifier.identify('', project, '123') + end + end + + context 'with a user identifier' do + it 'identifies the user using a user ID' do + expect(identifier).to receive(:identify_using_user). + with("user-#{user.id}") + + identifier.identify("user-#{user.id}", project, '123') + end + end + + context 'with an SSH key identifier' do + it 'identifies the user using an SSH key ID' do + expect(identifier).to receive(:identify_using_ssh_key). + with("key-#{key.id}") + + identifier.identify("key-#{key.id}", project, '123') + end + end + end + + describe '#identify_using_commit' do + it "returns the User for an existing commit author's Email address" do + commit = double(:commit, author_email: user.email) + + expect(project).to receive(:commit).with('123').and_return(commit) + + expect(identifier.identify_using_commit(project, '123')).to eq(user) + end + + it 'returns nil when no user could be found' do + allow(project).to receive(:commit).with('123').and_return(nil) + + expect(identifier.identify_using_commit(project, '123')).to be_nil + end + + it 'returns nil when the commit does not have an author Email' do + commit = double(:commit, author_email: nil) + + expect(project).to receive(:commit).with('123').and_return(commit) + + expect(identifier.identify_using_commit(project, '123')).to be_nil + end + + it 'caches the found users per Email' do + commit = double(:commit, author_email: user.email) + + expect(project).to receive(:commit).with('123').twice.and_return(commit) + expect(User).to receive(:find_by).once.and_call_original + + 2.times do + expect(identifier.identify_using_commit(project, '123')).to eq(user) + end + end + end + + describe '#identify_using_user' do + it 'returns the User for an existing ID in the identifier' do + found = identifier.identify_using_user("user-#{user.id}") + + expect(found).to eq(user) + end + + it 'returns nil for a non existing user ID' do + found = identifier.identify_using_user('user--1') + + expect(found).to be_nil + end + + it 'caches the found users per ID' do + expect(User).to receive(:find_by).once.and_call_original + + 2.times do + found = identifier.identify_using_user("user-#{user.id}") + + expect(found).to eq(user) + end + end + end + + describe '#identify_using_ssh_key' do + it 'returns the User for an existing SSH key' do + found = identifier.identify_using_ssh_key("key-#{key.id}") + + expect(found).to eq(user) + end + + it 'returns nil for an invalid SSH key' do + found = identifier.identify_using_ssh_key('key--1') + + expect(found).to be_nil + end + + it 'caches the found users per key' do + expect(User).to receive(:find_by_ssh_key_id).once.and_call_original + + 2.times do + found = identifier.identify_using_ssh_key("key-#{key.id}") + + expect(found).to eq(user) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml new file mode 100644 index 00000000000..5d5836e9bee --- /dev/null +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -0,0 +1,187 @@ +--- +issues: +- subscriptions +- award_emoji +- author +- assignee +- updated_by +- milestone +- notes +- label_links +- labels +- todos +- user_agent_detail +- moved_to +- events +- merge_requests_closing_issues +- metrics +events: +- author +- project +- target +notes: +- award_emoji +- project +- noteable +- author +- updated_by +- resolved_by +- todos +- events +label_links: +- target +- label +label: +- subscriptions +- project +- lists +- label_links +- issues +- merge_requests +milestone: +- project +- issues +- labels +- merge_requests +- participants +- events +snippets: +- author +- project +- notes +- award_emoji +releases: +- project +project_members: +- created_by +- user +- source +- project +merge_requests: +- subscriptions +- award_emoji +- author +- assignee +- updated_by +- milestone +- notes +- label_links +- labels +- todos +- target_project +- source_project +- merge_user +- merge_request_diffs +- merge_request_diff +- events +- merge_requests_closing_issues +- metrics +merge_request_diff: +- merge_request +pipelines: +- project +- user +- statuses +- builds +- trigger_requests +statuses: +- project +- pipeline +- user +variables: +- project +triggers: +- project +- trigger_requests +deploy_keys: +- user +- deploy_keys_projects +- projects +services: +- project +- service_hook +hooks: +- project +protected_branches: +- project +- merge_access_levels +- push_access_levels +merge_access_levels: +- protected_branch +push_access_levels: +- protected_branch +project: +- taggings +- base_tags +- tag_taggings +- tags +- creator +- group +- namespace +- boards +- last_event +- services +- campfire_service +- drone_ci_service +- emails_on_push_service +- builds_email_service +- irker_service +- pivotaltracker_service +- hipchat_service +- flowdock_service +- assembla_service +- asana_service +- gemnasium_service +- slack_service +- buildkite_service +- bamboo_service +- teamcity_service +- pushover_service +- jira_service +- redmine_service +- custom_issue_tracker_service +- bugzilla_service +- gitlab_issue_tracker_service +- external_wiki_service +- forked_project_link +- forked_from_project +- forked_project_links +- forks +- merge_requests +- fork_merge_requests +- issues +- labels +- events +- milestones +- notes +- snippets +- hooks +- protected_branches +- project_members +- users +- requesters +- deploy_keys_projects +- deploy_keys +- users_star_projects +- starrers +- releases +- lfs_objects_projects +- lfs_objects +- project_group_links +- invited_groups +- todos +- notification_settings +- import_data +- commit_statuses +- pipelines +- builds +- runner_projects +- runners +- variables +- triggers +- environments +- deployments +- project_feature +award_emoji: +- awardable +- user
\ No newline at end of file diff --git a/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb b/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb new file mode 100644 index 00000000000..b8e7932eb4a --- /dev/null +++ b/spec/lib/gitlab/import_export/attribute_cleaner_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::AttributeCleaner, lib: true do + let(:unsafe_hash) do + { + 'service_id' => 99, + 'moved_to_id' => 99, + 'namespace_id' => 99, + 'ci_id' => 99, + 'random_project_id' => 99, + 'random_id' => 99, + 'milestone_id' => 99, + 'project_id' => 99, + 'user_id' => 99, + 'random_id_in_the_middle' => 99, + 'notid' => 99 + } + end + + let(:post_safe_hash) do + { + 'project_id' => 99, + 'user_id' => 99, + 'random_id_in_the_middle' => 99, + 'notid' => 99 + } + end + + it 'removes unwanted attributes from the hash' do + described_class.clean!(relation_hash: unsafe_hash) + + expect(unsafe_hash).to eq(post_safe_hash) + end +end diff --git a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb new file mode 100644 index 00000000000..ea65a5dfed1 --- /dev/null +++ b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +# Part of the test security suite for the Import/Export feature +# Checks whether there are new attributes in models that are currently being exported as part of the +# project Import/Export feature. +# If there are new attributes, these will have to either be added to this spec in case we want them +# to be included as part of the export, or blacklist them using the import_export.yml configuration file. +# Likewise, new models added to import_export.yml, will need to be added with their correspondent attributes +# to this spec. +describe 'Import/Export attribute configuration', lib: true do + include ConfigurationHelper + + let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys } + let(:relation_names) do + names = names_from_tree(config_hash['project_tree']) + + # Remove duplicated or add missing models + # - project is not part of the tree, so it has to be added manually. + # - milestone, labels have both singular and plural versions in the tree, so remove the duplicates. + names.flatten.uniq - ['milestones', 'labels'] + ['project'] + end + + let(:safe_attributes_file) { 'spec/lib/gitlab/import_export/safe_model_attributes.yml' } + let(:safe_model_attributes) { YAML.load_file(safe_attributes_file) } + + it 'has no new columns' do + relation_names.each do |relation_name| + relation_class = relation_class_for_name(relation_name) + relation_attributes = relation_class.new.attributes.keys + + expect(safe_model_attributes[relation_class.to_s]).not_to be_nil, "Expected exported class #{relation_class} to exist in safe_model_attributes" + + current_attributes = parsed_attributes(relation_name, relation_attributes) + safe_attributes = safe_model_attributes[relation_class.to_s] + new_attributes = current_attributes - safe_attributes + + expect(new_attributes).to be_empty, failure_message(relation_class.to_s, new_attributes) + end + end + + def failure_message(relation_class, new_attributes) + <<-MSG + It looks like #{relation_class}, which is exported using the project Import/Export, has new attributes: #{new_attributes.join(',')} + + Please add the attribute(s) to SAFE_MODEL_ATTRIBUTES if you consider this can be exported. + Otherwise, please blacklist the attribute(s) in IMPORT_EXPORT_CONFIG by adding it to its correspondent + model in the +excluded_attributes+ section. + + SAFE_MODEL_ATTRIBUTES: #{File.expand_path(safe_attributes_file)} + IMPORT_EXPORT_CONFIG: #{Gitlab::ImportExport.config_file} + MSG + end + + class Author < User + end +end diff --git a/spec/lib/gitlab/import_export/members_mapper_spec.rb b/spec/lib/gitlab/import_export/members_mapper_spec.rb index 6d5aa0d04a2..770e8b0c2f4 100644 --- a/spec/lib/gitlab/import_export/members_mapper_spec.rb +++ b/spec/lib/gitlab/import_export/members_mapper_spec.rb @@ -26,6 +26,20 @@ describe Gitlab::ImportExport::MembersMapper, services: true do "email" => user2.email, "username" => user2.username } + }, + { + "id" => 3, + "access_level" => 40, + "source_id" => 14, + "source_type" => "Project", + "user_id" => nil, + "notification_level" => 3, + "created_at" => "2016-03-11T10:21:44.822Z", + "updated_at" => "2016-03-11T10:21:44.822Z", + "created_by_id" => 1, + "invite_email" => 'invite@test.com', + "invite_token" => 'token', + "invite_accepted_at" => nil }] end @@ -47,5 +61,11 @@ describe Gitlab::ImportExport::MembersMapper, services: true do expect(members_mapper.missing_author_ids.first).to eq(-1) end + + it 'has invited members with no user' do + members_mapper.map + + expect(ProjectMember.find_by_invite_email('invite@test.com')).not_to be_nil + end end end diff --git a/spec/lib/gitlab/import_export/model_configuration_spec.rb b/spec/lib/gitlab/import_export/model_configuration_spec.rb new file mode 100644 index 00000000000..9b492d1b9c7 --- /dev/null +++ b/spec/lib/gitlab/import_export/model_configuration_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +# Part of the test security suite for the Import/Export feature +# Finds if a new model has been added that can potentially be part of the Import/Export +# If it finds a new model, it will show a +failure_message+ with the options available. +describe 'Import/Export model configuration', lib: true do + include ConfigurationHelper + + let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys } + let(:model_names) do + names = names_from_tree(config_hash['project_tree']) + + # Remove duplicated or add missing models + # - project is not part of the tree, so it has to be added manually. + # - milestone, labels have both singular and plural versions in the tree, so remove the duplicates. + # - User, Author... Models we do not care about for checking models + names.flatten.uniq - ['milestones', 'labels', 'user', 'author'] + ['project'] + end + + let(:all_models_yml) { 'spec/lib/gitlab/import_export/all_models.yml' } + let(:all_models) { YAML.load_file(all_models_yml) } + let(:current_models) { setup_models } + + it 'has no new models' do + model_names.each do |model_name| + new_models = Array(current_models[model_name]) - Array(all_models[model_name]) + expect(new_models).to be_empty, failure_message(model_name.classify, new_models) + end + end + + # List of current models between models, in the format of + # {model: [model_2, model3], ...} + def setup_models + all_models_hash = {} + + model_names.each do |model_name| + model_class = relation_class_for_name(model_name) + + all_models_hash[model_name] = associations_for(model_class) - ['project'] + end + + all_models_hash + end + + def failure_message(parent_model_name, new_models) + <<-MSG + New model(s) <#{new_models.join(',')}> have been added, related to #{parent_model_name}, which is exported by + the Import/Export feature. + + If you think this model should be included in the export, please add it to IMPORT_EXPORT_CONFIG. + Definitely add it to MODELS_JSON to signal that you've handled this error and to prevent it from showing up in the future. + + MODELS_JSON: #{File.expand_path(all_models_yml)} + IMPORT_EXPORT_CONFIG: #{Gitlab::ImportExport.config_file} + MSG + end +end diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index b5550ca1963..98323fe6be4 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -1,9 +1,5 @@ { "description": "Nisi et repellendus ut enim quo accusamus vel magnam.", - "issues_enabled": true, - "merge_requests_enabled": true, - "wiki_enabled": true, - "snippets_enabled": false, "visibility_level": 10, "archived": false, "issues": [ @@ -28,7 +24,7 @@ "test_ee_field": "test", "milestone": { "id": 1, - "title": "v0.0", + "title": "test milestone", "project_id": 8, "description": "test milestone", "due_date": null, @@ -55,7 +51,7 @@ { "id": 2, "label_id": 2, - "target_id": 3, + "target_id": 40, "target_type": "Issue", "created_at": "2016-07-22T08:57:02.840Z", "updated_at": "2016-07-22T08:57:02.840Z", @@ -285,6 +281,31 @@ "deleted_at": null, "due_date": null, "moved_to_id": null, + "milestone": { + "id": 1, + "title": "test milestone", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "title": null, + "data": null, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + }, "notes": [ { "id": 359, @@ -498,6 +519,27 @@ "deleted_at": null, "due_date": null, "moved_to_id": null, + "label_links": [ + { + "id": 99, + "label_id": 2, + "target_id": 38, + "target_type": "Issue", + "created_at": "2016-07-22T08:57:02.840Z", + "updated_at": "2016-07-22T08:57:02.840Z", + "label": { + "id": 2, + "title": "test2", + "color": "#428bca", + "project_id": 8, + "created_at": "2016-07-22T08:55:44.161Z", + "updated_at": "2016-07-22T08:55:44.161Z", + "template": false, + "description": "", + "priority": null + } + } + ], "notes": [ { "id": 367, @@ -2190,6 +2232,31 @@ ], "milestones": [ { + "id": 1, + "title": "test milestone", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "title": null, + "data": null, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + }, + { "id": 20, "title": "v4.0", "project_id": 5, @@ -2393,7 +2460,7 @@ "source_project_id": 5, "author_id": 1, "assignee_id": null, - "title": "Cannot be automatically merged", + "title": "MR1", "created_at": "2016-06-14T15:02:36.568Z", "updated_at": "2016-06-14T15:02:56.815Z", "state": "opened", @@ -2827,10 +2894,10 @@ "id": 26, "target_branch": "master", "source_branch": "feature", - "source_project_id": 5, + "source_project_id": 4, "author_id": 1, "assignee_id": null, - "title": "Can be automatically merged", + "title": "MR2", "created_at": "2016-06-14T15:02:36.418Z", "updated_at": "2016-06-14T15:02:57.013Z", "state": "opened", @@ -6482,7 +6549,7 @@ { "id": 37, "project_id": 5, - "ref": "master", + "ref": null, "sha": "048721d90c449b244b7b4c53a9186b04330174ec", "before_sha": null, "push_data": null, @@ -6876,6 +6943,7 @@ "note_events": true, "build_events": true, "category": "issue_tracker", + "type": "CustomIssueTrackerService", "default": true, "wiki_page_events": true }, @@ -7305,6 +7373,41 @@ ], "protected_branches": [ - - ] + { + "id": 1, + "project_id": 9, + "name": "master", + "created_at": "2016-08-30T07:32:52.426Z", + "updated_at": "2016-08-30T07:32:52.426Z", + "merge_access_levels": [ + { + "id": 1, + "protected_branch_id": 1, + "access_level": 40, + "created_at": "2016-08-30T07:32:52.458Z", + "updated_at": "2016-08-30T07:32:52.458Z" + } + ], + "push_access_levels": [ + { + "id": 1, + "protected_branch_id": 1, + "access_level": 40, + "created_at": "2016-08-30T07:32:52.490Z", + "updated_at": "2016-08-30T07:32:52.490Z" + } + ] + } + ], + "project_feature": { + "builds_access_level": 0, + "created_at": "2014-12-26T09:26:45.000Z", + "id": 2, + "issues_access_level": 0, + "merge_requests_access_level": 20, + "project_id": 4, + "snippets_access_level": 20, + "updated_at": "2016-09-23T11:58:28.000Z", + "wiki_access_level": 20 + } }
\ No newline at end of file diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 32c0d6462f1..7582a732cdf 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -2,11 +2,10 @@ require 'spec_helper' describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do describe 'restore project tree' do - let(:user) { create(:user) } let(:namespace) { create(:namespace, owner: user) } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: "", project_path: 'path') } - let(:project) { create(:empty_project, name: 'project', path: 'project') } + let!(:project) { create(:empty_project, name: 'project', path: 'project', builds_access_level: ProjectFeature::DISABLED, issues_access_level: ProjectFeature::DISABLED) } let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) } let(:restored_project_json) { project_tree_restorer.restore } @@ -19,12 +18,41 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect(restored_project_json).to be true end + it 'restore correct project features' do + restored_project_json + project = Project.find_by_path('project') + + expect(project.project_feature.issues_access_level).to eq(ProjectFeature::DISABLED) + expect(project.project_feature.builds_access_level).to eq(ProjectFeature::DISABLED) + expect(project.project_feature.snippets_access_level).to eq(ProjectFeature::ENABLED) + expect(project.project_feature.wiki_access_level).to eq(ProjectFeature::ENABLED) + expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED) + end + + it 'has the same label associated to two issues' do + restored_project_json + + expect(Label.first.issues.count).to eq(2) + end + + it 'has milestones associated to two separate issues' do + restored_project_json + + expect(Milestone.find_by_description('test milestone').issues.count).to eq(2) + end + it 'creates a valid pipeline note' do restored_project_json expect(Ci::Pipeline.first.notes).not_to be_empty end + it 'restores pipelines with missing ref' do + restored_project_json + + expect(Ci::Pipeline.where(ref: nil)).not_to be_empty + end + it 'restores the correct event with symbolised data' do restored_project_json @@ -39,6 +67,18 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect(issue.reload.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC') end + it 'contains the merge access levels on a protected branch' do + restored_project_json + + expect(ProtectedBranch.first.merge_access_levels).not_to be_empty + end + + it 'contains the push access levels on a protected branch' do + restored_project_json + + expect(ProtectedBranch.first.push_access_levels).not_to be_empty + end + context 'event at forth level of the tree' do let(:event) { Event.where(title: 'test levels').first } @@ -67,10 +107,38 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect(Label.first.label_links.first.target).not_to be_nil end - it 'has milestones associated to issues' do + it 'has a project feature' do restored_project_json - expect(Milestone.find_by_description('test milestone').issues).not_to be_empty + expect(project.project_feature).not_to be_nil + end + + it 'restores the correct service' do + restored_project_json + + expect(CustomIssueTrackerService.first).not_to be_nil + end + + context 'Merge requests' do + before do + restored_project_json + end + + it 'always has the new project as a target' do + expect(MergeRequest.find_by_title('MR1').target_project).to eq(project) + end + + it 'has the same source project as originally if source/target are the same' do + expect(MergeRequest.find_by_title('MR1').source_project).to eq(project) + end + + it 'has the new project as target if source/target differ' do + expect(MergeRequest.find_by_title('MR2').target_project).to eq(project) + end + + it 'has no source if source/target differ' do + expect(MergeRequest.find_by_title('MR2').source_project_id).to eq(-1) + end end end end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 3a86a4ce07c..cf8f2200c57 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -111,6 +111,18 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do expect(saved_project_json['issues'].first['label_links'].first['label']).not_to be_empty end + it 'saves the correct service type' do + expect(saved_project_json['services'].first['type']).to eq('CustomIssueTrackerService') + end + + it 'has project feature' do + project_feature = saved_project_json['project_feature'] + expect(project_feature).not_to be_empty + expect(project_feature["issues_access_level"]).to eq(ProjectFeature::DISABLED) + expect(project_feature["wiki_access_level"]).to eq(ProjectFeature::ENABLED) + expect(project_feature["builds_access_level"]).to eq(ProjectFeature::PRIVATE) + end + it 'does not complain about non UTF-8 characters in MR diffs' do ActiveRecord::Base.connection.execute("UPDATE merge_request_diffs SET st_diffs = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'") @@ -153,6 +165,11 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do commit_id: ci_pipeline.sha) create(:event, target: milestone, project: project, action: Event::CREATED, author: user) + create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker') + + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::ENABLED) + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::PRIVATE) project end diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb index b76e14deca1..3ceb1e7e803 100644 --- a/spec/lib/gitlab/import_export/reader_spec.rb +++ b/spec/lib/gitlab/import_export/reader_spec.rb @@ -12,7 +12,8 @@ describe Gitlab::ImportExport::Reader, lib: true do except: [:iid], include: [:merge_request_diff, :merge_request_test] } }, - { commit_statuses: { include: :commit } }] + { commit_statuses: { include: :commit } }, + { project_members: { include: { user: { only: [:email] } } } }] } end @@ -31,6 +32,12 @@ describe Gitlab::ImportExport::Reader, lib: true do expect(described_class.new(shared: shared).project_tree).to match(include: [:issues]) end + it 'generates the correct hash for a single project feature relation' do + setup_yaml(project_tree: [:project_feature]) + + expect(described_class.new(shared: shared).project_tree).to match(include: [:project_feature]) + end + it 'generates the correct hash for a multiple project relation' do setup_yaml(project_tree: [:issues, :snippets]) diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb new file mode 100644 index 00000000000..3aa492a8ab1 --- /dev/null +++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb @@ -0,0 +1,125 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::RelationFactory, lib: true do + let(:project) { create(:empty_project) } + let(:members_mapper) { double('members_mapper').as_null_object } + let(:user) { create(:user) } + let(:created_object) do + described_class.create(relation_sym: relation_sym, + relation_hash: relation_hash, + members_mapper: members_mapper, + user: user, + project_id: project.id) + end + + context 'hook object' do + let(:relation_sym) { :hooks } + let(:id) { 999 } + let(:service_id) { 99 } + let(:original_project_id) { 8 } + let(:token) { 'secret' } + + let(:relation_hash) do + { + 'id' => id, + 'url' => 'https://example.json', + 'project_id' => original_project_id, + 'created_at' => '2016-08-12T09:41:03.462Z', + 'updated_at' => '2016-08-12T09:41:03.462Z', + 'service_id' => service_id, + 'push_events' => true, + 'issues_events' => false, + 'merge_requests_events' => true, + 'tag_push_events' => false, + 'note_events' => true, + 'enable_ssl_verification' => true, + 'build_events' => false, + 'wiki_page_events' => true, + 'token' => token + } + end + + it 'does not have the original ID' do + expect(created_object.id).not_to eq(id) + end + + it 'does not have the original service_id' do + expect(created_object.service_id).not_to eq(service_id) + end + + it 'does not have the original project_id' do + expect(created_object.project_id).not_to eq(original_project_id) + end + + it 'has the new project_id' do + expect(created_object.project_id).to eq(project.id) + end + + it 'has a token' do + expect(created_object.token).to eq(token) + end + + context 'original service exists' do + let(:service_id) { Service.create(project: project).id } + + it 'does not have the original service_id' do + expect(created_object.service_id).not_to eq(service_id) + end + end + end + + # Mocks an ActiveRecordish object with the dodgy columns + class FooModel + include ActiveModel::Model + + def initialize(params) + params.each { |key, value| send("#{key}=", value) } + end + + def values + instance_variables.map { |ivar| instance_variable_get(ivar) } + end + end + + # `project_id`, `described_class.USER_REFERENCES`, noteable_id, target_id, and some project IDs are already + # re-assigned by described_class. + context 'Potentially hazardous foreign keys' do + let(:relation_sym) { :hazardous_foo_model } + let(:relation_hash) do + { + 'service_id' => 99, + 'moved_to_id' => 99, + 'namespace_id' => 99, + 'ci_id' => 99, + 'random_project_id' => 99, + 'random_id' => 99, + 'milestone_id' => 99, + 'project_id' => 99, + 'user_id' => 99, + } + end + + class HazardousFooModel < FooModel + attr_accessor :service_id, :moved_to_id, :namespace_id, :ci_id, :random_project_id, :random_id, :milestone_id, :project_id + end + + it 'does not preserve any foreign key IDs' do + expect(created_object.values).not_to include(99) + end + end + + context 'Project references' do + let(:relation_sym) { :project_foo_model } + let(:relation_hash) do + Gitlab::ImportExport::RelationFactory::PROJECT_REFERENCES.map { |ref| { ref => 99 } }.inject(:merge) + end + + class ProjectFooModel < FooModel + attr_accessor(*Gitlab::ImportExport::RelationFactory::PROJECT_REFERENCES) + end + + it 'does not preserve any project foreign key IDs' do + expect(created_object.values).not_to include(99) + end + end +end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml new file mode 100644 index 00000000000..8bccd313d6c --- /dev/null +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -0,0 +1,330 @@ +--- +Issue: +- id +- title +- assignee_id +- author_id +- project_id +- created_at +- updated_at +- position +- branch_name +- description +- state +- iid +- updated_by_id +- confidential +- deleted_at +- due_date +- moved_to_id +- lock_version +- milestone_id +- weight +Event: +- id +- target_type +- target_id +- title +- data +- project_id +- created_at +- updated_at +- action +- author_id +Note: +- id +- note +- noteable_type +- author_id +- created_at +- updated_at +- project_id +- attachment +- line_code +- commit_id +- noteable_id +- system +- st_diff +- updated_by_id +- type +- position +- original_position +- resolved_at +- resolved_by_id +- discussion_id +- original_discussion_id +LabelLink: +- id +- label_id +- target_id +- target_type +- created_at +- updated_at +Label: +- id +- title +- color +- project_id +- created_at +- updated_at +- template +- description +- priority +Milestone: +- id +- title +- project_id +- description +- due_date +- created_at +- updated_at +- state +- iid +ProjectSnippet: +- id +- title +- content +- author_id +- project_id +- created_at +- updated_at +- file_name +- type +- visibility_level +Release: +- id +- tag +- description +- project_id +- created_at +- updated_at +ProjectMember: +- id +- access_level +- source_id +- source_type +- user_id +- notification_level +- type +- created_at +- updated_at +- created_by_id +- invite_email +- invite_token +- invite_accepted_at +- requested_at +- expires_at +User: +- id +- username +- email +MergeRequest: +- id +- target_branch +- source_branch +- source_project_id +- author_id +- assignee_id +- title +- created_at +- updated_at +- state +- merge_status +- target_project_id +- iid +- description +- position +- locked_at +- updated_by_id +- merge_error +- merge_params +- merge_when_build_succeeds +- merge_user_id +- merge_commit_sha +- deleted_at +- in_progress_merge_commit_sha +- lock_version +- milestone_id +- approvals_before_merge +- rebase_commit_sha +MergeRequestDiff: +- id +- state +- st_commits +- merge_request_id +- created_at +- updated_at +- base_commit_sha +- real_size +- head_commit_sha +- start_commit_sha +Ci::Pipeline: +- id +- project_id +- ref +- sha +- before_sha +- push_data +- created_at +- updated_at +- tag +- yaml_errors +- committed_at +- gl_project_id +- status +- started_at +- finished_at +- duration +- user_id +CommitStatus: +- id +- project_id +- status +- finished_at +- trace +- created_at +- updated_at +- started_at +- runner_id +- coverage +- commit_id +- commands +- job_id +- name +- deploy +- options +- allow_failure +- stage +- trigger_request_id +- stage_idx +- tag +- ref +- user_id +- type +- target_url +- description +- artifacts_file +- gl_project_id +- artifacts_metadata +- erased_by_id +- erased_at +- artifacts_expire_at +- environment +- artifacts_size +- when +- yaml_variables +- queued_at +- token +Ci::Variable: +- id +- project_id +- key +- value +- encrypted_value +- encrypted_value_salt +- encrypted_value_iv +- gl_project_id +Ci::Trigger: +- id +- token +- project_id +- deleted_at +- created_at +- updated_at +- gl_project_id +DeployKey: +- id +- user_id +- created_at +- updated_at +- key +- title +- type +- fingerprint +- public +Service: +- id +- type +- title +- project_id +- created_at +- updated_at +- active +- properties +- template +- push_events +- issues_events +- merge_requests_events +- tag_push_events +- note_events +- pipeline_events +- build_events +- category +- default +- wiki_page_events +- confidential_issues_events +ProjectHook: +- id +- url +- project_id +- created_at +- updated_at +- type +- service_id +- push_events +- issues_events +- merge_requests_events +- tag_push_events +- note_events +- pipeline_events +- enable_ssl_verification +- build_events +- wiki_page_events +- token +- group_id +- confidential_issues_events +ProtectedBranch: +- id +- project_id +- name +- created_at +- updated_at +Project: +- description +- issues_enabled +- merge_requests_enabled +- wiki_enabled +- snippets_enabled +- visibility_level +- archived +Author: +- name +ProjectFeature: +- id +- project_id +- merge_requests_access_level +- issues_access_level +- wiki_access_level +- snippets_access_level +- builds_access_level +- created_at +- updated_at +ProtectedBranch::MergeAccessLevel: +- id +- protected_branch_id +- access_level +- created_at +- updated_at +ProtectedBranch::PushAccessLevel: +- id +- protected_branch_id +- access_level +- created_at +- updated_at +AwardEmoji: +- id +- user_id +- name +- awardable_type +- created_at +- updated_at diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb index 90c6d1c67f6..c680e712b59 100644 --- a/spec/lib/gitlab/import_export/version_checker_spec.rb +++ b/spec/lib/gitlab/import_export/version_checker_spec.rb @@ -23,7 +23,7 @@ describe Gitlab::ImportExport::VersionChecker, services: true do it 'shows the correct error message' do described_class.check!(shared: shared) - expect(shared.errors.first).to eq("Import version mismatch: Required <= #{Gitlab::ImportExport.version} but was #{version}") + expect(shared.errors.first).to eq("Import version mismatch: Required #{Gitlab::ImportExport.version} but was #{version}") end end end diff --git a/spec/lib/gitlab/ldap/access_spec.rb b/spec/lib/gitlab/ldap/access_spec.rb index acd5394382c..534bcbf39fe 100644 --- a/spec/lib/gitlab/ldap/access_spec.rb +++ b/spec/lib/gitlab/ldap/access_spec.rb @@ -64,7 +64,7 @@ describe Gitlab::LDAP::Access, lib: true do user.ldap_block end - it 'should unblock user in GitLab' do + it 'unblocks user in GitLab' do access.allowed? expect(user).not_to be_blocked end diff --git a/spec/lib/gitlab/ldap/adapter_spec.rb b/spec/lib/gitlab/ldap/adapter_spec.rb index 4847b5f3b0e..563c074017a 100644 --- a/spec/lib/gitlab/ldap/adapter_spec.rb +++ b/spec/lib/gitlab/ldap/adapter_spec.rb @@ -1,24 +1,105 @@ require 'spec_helper' describe Gitlab::LDAP::Adapter, lib: true do - let(:adapter) { Gitlab::LDAP::Adapter.new 'ldapmain' } + include LdapHelpers + + let(:ldap) { double(:ldap) } + let(:adapter) { ldap_adapter('ldapmain', ldap) } + + describe '#users' do + before do + stub_ldap_config(base: 'dc=example,dc=com') + end + + it 'searches with the proper options when searching by uid' do + # Requires this expectation style to match the filter + expect(adapter).to receive(:ldap_search) do |arg| + expect(arg[:filter].to_s).to eq('(uid=johndoe)') + expect(arg[:base]).to eq('dc=example,dc=com') + expect(arg[:attributes]).to match(%w{uid cn mail dn}) + end.and_return({}) + + adapter.users('uid', 'johndoe') + end + + it 'searches with the proper options when searching by dn' do + expect(adapter).to receive(:ldap_search).with( + base: 'uid=johndoe,ou=users,dc=example,dc=com', + scope: Net::LDAP::SearchScope_BaseObject, + attributes: %w{uid cn mail dn}, + filter: nil + ).and_return({}) + + adapter.users('dn', 'uid=johndoe,ou=users,dc=example,dc=com') + end + + it 'searches with the proper options when searching with a limit' do + expect(adapter) + .to receive(:ldap_search).with(hash_including(size: 100)).and_return({}) + + adapter.users('uid', 'johndoe', 100) + end + + it 'returns an LDAP::Person if search returns a result' do + entry = ldap_user_entry('johndoe') + allow(adapter).to receive(:ldap_search).and_return([entry]) + + results = adapter.users('uid', 'johndoe') + + expect(results.size).to eq(1) + expect(results.first.uid).to eq('johndoe') + end + + it 'returns empty array if search entry does not respond to uid' do + entry = Net::LDAP::Entry.new + entry['dn'] = user_dn('johndoe') + allow(adapter).to receive(:ldap_search).and_return([entry]) + + results = adapter.users('uid', 'johndoe') + + expect(results).to be_empty + end + + it 'uses the right uid attribute when non-default' do + stub_ldap_config(uid: 'sAMAccountName') + expect(adapter).to receive(:ldap_search).with( + hash_including(attributes: %w{sAMAccountName cn mail dn}) + ).and_return({}) + + adapter.users('sAMAccountName', 'johndoe') + end + end describe '#dn_matches_filter?' do - let(:ldap) { double(:ldap) } subject { adapter.dn_matches_filter?(:dn, :filter) } - before { allow(adapter).to receive(:ldap).and_return(ldap) } + + context "when the search result is non-empty" do + before { allow(adapter).to receive(:ldap_search).and_return([:foo]) } + + it { is_expected.to be_truthy } + end + + context "when the search result is empty" do + before { allow(adapter).to receive(:ldap_search).and_return([]) } + + it { is_expected.to be_falsey } + end + end + + describe '#ldap_search' do + subject { adapter.ldap_search(base: :dn, filter: :filter) } context "when the search is successful" do context "and the result is non-empty" do before { allow(ldap).to receive(:search).and_return([:foo]) } - it { is_expected.to be_truthy } + it { is_expected.to eq [:foo] } end context "and the result is empty" do before { allow(ldap).to receive(:search).and_return([]) } - it { is_expected.to be_falsey } + it { is_expected.to eq [] } end end @@ -30,7 +111,22 @@ describe Gitlab::LDAP::Adapter, lib: true do ) end - it { is_expected.to be_falsey } + it { is_expected.to eq [] } + end + + context "when the search raises an LDAP exception" do + before do + allow(ldap).to receive(:search) { raise Net::LDAP::Error, "some error" } + allow(Rails.logger).to receive(:warn) + end + + it { is_expected.to eq [] } + + it 'logs the error' do + subject + expect(Rails.logger).to have_received(:warn).with( + "LDAP search raised exception Net::LDAP::Error: some error") + end end end end diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb index 949f6e2b19a..89790c9e1af 100644 --- a/spec/lib/gitlab/ldap/user_spec.rb +++ b/spec/lib/gitlab/ldap/user_spec.rb @@ -36,7 +36,7 @@ describe Gitlab::LDAP::User, lib: true do expect(ldap_user.changed?).to be_truthy end - it "dont marks existing ldap user as changed" do + it "does not mark existing ldap user as changed" do create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain', ldap_email: true) expect(ldap_user.changed?).to be_falsey end diff --git a/spec/lib/gitlab/lfs_token_spec.rb b/spec/lib/gitlab/lfs_token_spec.rb new file mode 100644 index 00000000000..e9c1163e22a --- /dev/null +++ b/spec/lib/gitlab/lfs_token_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Gitlab::LfsToken, lib: true do + describe '#token' do + shared_examples 'an LFS token generator' do + it 'returns a randomly generated token' do + token = handler.token + + expect(token).not_to be_nil + expect(token).to be_a String + expect(token.length).to eq 50 + end + + it 'returns the correct token based on the key' do + token = handler.token + + expect(handler.token).to eq(token) + end + end + + context 'when the actor is a user' do + let(:actor) { create(:user) } + let(:handler) { described_class.new(actor) } + + it_behaves_like 'an LFS token generator' + + it 'returns the correct username' do + expect(handler.actor_name).to eq(actor.username) + end + + it 'returns the correct token type' do + expect(handler.type).to eq(:lfs_token) + end + end + + context 'when the actor is a deploy key' do + let(:actor) { create(:deploy_key) } + let(:handler) { described_class.new(actor) } + + it_behaves_like 'an LFS token generator' + + it 'returns the correct username' do + expect(handler.actor_name).to eq("lfs+deploy-key-#{actor.id}") + end + + it 'returns the correct token type' do + expect(handler.type).to eq(:lfs_deploy_token) + end + end + end +end diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb index f718d536130..f26fca52c50 100644 --- a/spec/lib/gitlab/metrics/metric_spec.rb +++ b/spec/lib/gitlab/metrics/metric_spec.rb @@ -23,6 +23,24 @@ describe Gitlab::Metrics::Metric do it { is_expected.to eq({ host: 'localtoast' }) } end + describe '#type' do + subject { metric.type } + + it { is_expected.to eq(:metric) } + end + + describe '#event?' do + it 'returns false for a regular metric' do + expect(metric.event?).to eq(false) + end + + it 'returns true for an event metric' do + expect(metric).to receive(:type).and_return(:event) + + expect(metric.event?).to eq(true) + end + end + describe '#to_hash' do it 'returns a Hash' do expect(metric.to_hash).to be_an_instance_of(Hash) diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index f264ed64029..bcaffd27909 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -19,7 +19,7 @@ describe Gitlab::Metrics::RackMiddleware do end it 'tags a transaction with the name and action of a controller' do - klass = double(:klass, name: 'TestController') + klass = double(:klass, name: 'TestController', content_type: 'text/html') controller = double(:controller, class: klass, action_name: 'show') env['action_controller.instance'] = controller @@ -32,7 +32,7 @@ describe Gitlab::Metrics::RackMiddleware do middleware.call(env) end - it 'tags a transaction with the method andpath of the route in the grape endpoint' do + it 'tags a transaction with the method and path of the route in the grape endpoint' do route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") endpoint = double(:endpoint, route: route) @@ -45,6 +45,15 @@ describe Gitlab::Metrics::RackMiddleware do middleware.call(env) end + + it 'tracks any raised exceptions' do + expect(app).to receive(:call).with(env).and_raise(RuntimeError) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:add_event).with(:rails_exception) + + expect { middleware.call(env) }.to raise_error(RuntimeError) + end end describe '#transaction_from_env' do @@ -78,17 +87,30 @@ describe Gitlab::Metrics::RackMiddleware do describe '#tag_controller' do let(:transaction) { middleware.transaction_from_env(env) } + let(:content_type) { 'text/html' } - it 'tags a transaction with the name and action of a controller' do + before do klass = double(:klass, name: 'TestController') - controller = double(:controller, class: klass, action_name: 'show') + controller = double(:controller, class: klass, action_name: 'show', content_type: content_type) env['action_controller.instance'] = controller + end + it 'tags a transaction with the name and action of a controller' do middleware.tag_controller(transaction, env) expect(transaction.action).to eq('TestController#show') end + + context 'when the response content type is not :html' do + let(:content_type) { 'application/json' } + + it 'appends the mime type to the transaction action' do + middleware.tag_controller(transaction, env) + + expect(transaction.action).to eq('TestController#show.json') + end + end end describe '#tag_endpoint' do diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb index 4d2aa03e722..acaba785606 100644 --- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb @@ -12,7 +12,9 @@ describe Gitlab::Metrics::SidekiqMiddleware do with('TestWorker#perform'). and_call_original - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set). + with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) middleware.call(worker, message, :test) { nil } @@ -25,10 +27,28 @@ describe Gitlab::Metrics::SidekiqMiddleware do with('TestWorker#perform'). and_call_original - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set).with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set). + with(:sidekiq_queue_duration, instance_of(Float)) + expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish) middleware.call(worker, {}, :test) { nil } end + + it 'tracks any raised exceptions' do + worker = double(:worker, class: double(:class, name: 'TestWorker')) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:run).and_raise(RuntimeError) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:add_event).with(:sidekiq_exception) + + expect_any_instance_of(Gitlab::Metrics::Transaction). + to receive(:finish) + + expect { middleware.call(worker, message, :test) }. + to raise_error(RuntimeError) + end end end diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb index f1a191d9410..3887c04c832 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/transaction_spec.rb @@ -142,5 +142,62 @@ describe Gitlab::Metrics::Transaction do transaction.submit end + + it 'does not add an action tag for events' do + transaction.action = 'Foo#bar' + transaction.add_event(:meow) + + hash = { + series: 'events', + tags: { event: :meow }, + values: { count: 1 }, + timestamp: an_instance_of(Fixnum) + } + + expect(Gitlab::Metrics).to receive(:submit_metrics). + with([hash]) + + transaction.submit + end + end + + describe '#add_event' do + it 'adds a metric' do + transaction.add_event(:meow) + + expect(transaction.metrics[0]).to be_an_instance_of(Gitlab::Metrics::Metric) + end + + it "does not prefix the metric's series name" do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.series).to eq(described_class::EVENT_SERIES) + end + + it 'tracks a counter for every event' do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.values).to eq(count: 1) + end + + it 'tracks the event name' do + transaction.add_event(:meow) + + metric = transaction.metrics[0] + + expect(metric.tags).to eq(event: :meow) + end + + it 'allows tracking of custom tags' do + transaction.add_event(:meow, animal: 'cat') + + metric = transaction.metrics[0] + + expect(metric.tags).to eq(event: :meow, animal: 'cat') + end end end diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index 84f9475a0f8..ab6e311b1e8 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -153,4 +153,28 @@ describe Gitlab::Metrics do expect(described_class.series_prefix).to be_an_instance_of(String) end end + + describe '.add_event' do + context 'without a transaction' do + it 'does nothing' do + expect_any_instance_of(Gitlab::Metrics::Transaction). + not_to receive(:add_event) + + Gitlab::Metrics.add_event(:meow) + end + end + + context 'with a transaction' do + it 'adds an event' do + transaction = Gitlab::Metrics::Transaction.new + + expect(transaction).to receive(:add_event).with(:meow) + + expect(Gitlab::Metrics).to receive(:current_transaction). + and_return(transaction) + + Gitlab::Metrics.add_event(:meow) + end + end + end end diff --git a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb index fd6f684db0c..168090d5b5c 100644 --- a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb +++ b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb @@ -22,7 +22,7 @@ describe Gitlab::Middleware::RailsQueueDuration do end it 'sets proxy_flight_time and calls the app when the header is present' do - env['HTTP_GITLAB_WORHORSE_PROXY_START'] = '123' + env['HTTP_GITLAB_WORKHORSE_PROXY_START'] = '123' expect(transaction).to receive(:set).with(:rails_queue_duration, an_instance_of(Float)) expect(middleware.call(env)).to eq('yay') end diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb index 1fca8a13037..78c669e8fa5 100644 --- a/spec/lib/gitlab/o_auth/user_spec.rb +++ b/spec/lib/gitlab/o_auth/user_spec.rb @@ -42,7 +42,7 @@ describe Gitlab::OAuth::User, lib: true do describe 'signup' do shared_examples 'to verify compliance with allow_single_sign_on' do context 'provider is marked as external' do - it 'should mark user as external' do + it 'marks user as external' do stub_omniauth_config(allow_single_sign_on: ['twitter'], external_providers: ['twitter']) oauth_user.save expect(gl_user).to be_valid @@ -51,7 +51,7 @@ describe Gitlab::OAuth::User, lib: true do end context 'provider was external, now has been removed' do - it 'should not mark external user as internal' do + it 'does not mark external user as internal' do create(:omniauth_user, extern_uid: 'my-uid', provider: 'twitter', external: true) stub_omniauth_config(allow_single_sign_on: ['twitter'], external_providers: ['facebook']) oauth_user.save @@ -62,7 +62,7 @@ describe Gitlab::OAuth::User, lib: true do context 'provider is not external' do context 'when adding a new OAuth identity' do - it 'should not promote an external user to internal' do + it 'does not promote an external user to internal' do user = create(:user, email: 'john@mail.com', external: true) user.identities.create(provider: provider, extern_uid: uid) diff --git a/spec/lib/gitlab/popen_spec.rb b/spec/lib/gitlab/popen_spec.rb index e8b236426e9..4ae216d55b0 100644 --- a/spec/lib/gitlab/popen_spec.rb +++ b/spec/lib/gitlab/popen_spec.rb @@ -40,4 +40,13 @@ describe 'Gitlab::Popen', lib: true, no_db: true do it { expect(@status).to be_zero } it { expect(@output).to include('spec') } end + + context 'use stdin' do + before do + @output, @status = @klass.new.popen(%w[cat]) { |stdin| stdin.write 'hello' } + end + + it { expect(@status).to be_zero } + it { expect(@output).to eq('hello') } + end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 270b89972d7..29abb4d4d07 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::ProjectSearchResults, lib: true do let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } - it 'should not list project confidential issues for non project members' do + it 'does not list project confidential issues for non project members' do results = described_class.new(non_member, project, query) issues = results.objects('issues') @@ -43,7 +43,7 @@ describe Gitlab::ProjectSearchResults, lib: true do expect(results.issues_count).to eq 1 end - it 'should not list project confidential issues for project members with guest role' do + it 'does not list project confidential issues for project members with guest role' do project.team << [member, :guest] results = described_class.new(member, project, query) @@ -55,7 +55,7 @@ describe Gitlab::ProjectSearchResults, lib: true do expect(results.issues_count).to eq 1 end - it 'should list project confidential issues for author' do + it 'lists project confidential issues for author' do results = described_class.new(author, project, query) issues = results.objects('issues') @@ -65,7 +65,7 @@ describe Gitlab::ProjectSearchResults, lib: true do expect(results.issues_count).to eq 2 end - it 'should list project confidential issues for assignee' do + it 'lists project confidential issues for assignee' do results = described_class.new(assignee, project.id, query) issues = results.objects('issues') @@ -75,7 +75,7 @@ describe Gitlab::ProjectSearchResults, lib: true do expect(results.issues_count).to eq 2 end - it 'should list project confidential issues for project members' do + it 'lists project confidential issues for project members' do project.team << [member, :developer] results = described_class.new(member, project, query) @@ -87,7 +87,7 @@ describe Gitlab::ProjectSearchResults, lib: true do expect(results.issues_count).to eq 3 end - it 'should list all project issues for admin' do + it 'lists all project issues for admin' do results = described_class.new(admin, project, query) issues = results.objects('issues') diff --git a/spec/lib/gitlab/redis_spec.rb b/spec/lib/gitlab/redis_spec.rb new file mode 100644 index 00000000000..74ff85e132a --- /dev/null +++ b/spec/lib/gitlab/redis_spec.rb @@ -0,0 +1,151 @@ +require 'spec_helper' + +describe Gitlab::Redis do + let(:redis_config) { Rails.root.join('config', 'resque.yml').to_s } + + before(:each) { clear_raw_config } + after(:each) { clear_raw_config } + + describe '.params' do + subject { described_class.params } + + it 'withstands mutation' do + params1 = described_class.params + params2 = described_class.params + params1[:foo] = :bar + + expect(params2).not_to have_key(:foo) + end + + context 'when url contains unix socket reference' do + let(:config_old) { Rails.root.join('spec/fixtures/config/redis_old_format_socket.yml').to_s } + let(:config_new) { Rails.root.join('spec/fixtures/config/redis_new_format_socket.yml').to_s } + + context 'with old format' do + it 'returns path key instead' do + stub_const("#{described_class}::CONFIG_FILE", config_old) + + is_expected.to include(path: '/path/to/old/redis.sock') + is_expected.not_to have_key(:url) + end + end + + context 'with new format' do + it 'returns path key instead' do + stub_const("#{described_class}::CONFIG_FILE", config_new) + + is_expected.to include(path: '/path/to/redis.sock') + is_expected.not_to have_key(:url) + end + end + end + + context 'when url is host based' do + let(:config_old) { Rails.root.join('spec/fixtures/config/redis_old_format_host.yml') } + let(:config_new) { Rails.root.join('spec/fixtures/config/redis_new_format_host.yml') } + + context 'with old format' do + it 'returns hash with host, port, db, and password' do + stub_const("#{described_class}::CONFIG_FILE", config_old) + + is_expected.to include(host: 'localhost', password: 'mypassword', port: 6379, db: 99) + is_expected.not_to have_key(:url) + end + end + + context 'with new format' do + it 'returns hash with host, port, db, and password' do + stub_const("#{described_class}::CONFIG_FILE", config_new) + + is_expected.to include(host: 'localhost', password: 'mynewpassword', port: 6379, db: 99) + is_expected.not_to have_key(:url) + end + end + end + end + + describe '.url' do + it 'withstands mutation' do + url1 = described_class.url + url2 = described_class.url + url1 << 'foobar' + + expect(url2).not_to end_with('foobar') + end + end + + describe '._raw_config' do + subject { described_class._raw_config } + + it 'should be frozen' do + expect(subject).to be_frozen + end + + it 'returns false when the file does not exist' do + stub_const("#{described_class}::CONFIG_FILE", '/var/empty/doesnotexist') + + expect(subject).to eq(false) + end + end + + describe '.with' do + before { clear_pool } + after { clear_pool } + + context 'when running not on sidekiq workers' do + before { allow(Sidekiq).to receive(:server?).and_return(false) } + + it 'instantiates a connection pool with size 5' do + expect(ConnectionPool).to receive(:new).with(size: 5).and_call_original + + described_class.with { |_redis| true } + end + end + + context 'when running on sidekiq workers' do + before do + allow(Sidekiq).to receive(:server?).and_return(true) + allow(Sidekiq).to receive(:options).and_return({ concurrency: 18 }) + end + + it 'instantiates a connection pool with a size based on the concurrency of the worker' do + expect(ConnectionPool).to receive(:new).with(size: 18 + 5).and_call_original + + described_class.with { |_redis| true } + end + end + end + + describe '#raw_config_hash' do + it 'returns default redis url when no config file is present' do + expect(subject).to receive(:fetch_config) { false } + + expect(subject.send(:raw_config_hash)).to eq(url: Gitlab::Redis::DEFAULT_REDIS_URL) + end + + it 'returns old-style single url config in a hash' do + expect(subject).to receive(:fetch_config) { 'redis://myredis:6379' } + expect(subject.send(:raw_config_hash)).to eq(url: 'redis://myredis:6379') + end + end + + describe '#fetch_config' do + it 'returns false when no config file is present' do + allow(described_class).to receive(:_raw_config) { false } + + expect(subject.send(:fetch_config)).to be_falsey + end + end + + def clear_raw_config + described_class.remove_instance_variable(:@_raw_config) + rescue NameError + # raised if @_raw_config was not set; ignore + end + + def clear_pool + described_class.remove_instance_variable(:@pool) + rescue NameError + # raised if @pool was not set; ignore + end +end diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb index 56bf08e7041..02c139f1a0d 100644 --- a/spec/lib/gitlab/saml/user_spec.rb +++ b/spec/lib/gitlab/saml/user_spec.rb @@ -67,7 +67,7 @@ describe Gitlab::Saml::User, lib: true do end context 'user was external, now should not be' do - it 'should make user internal' do + it 'makes user internal' do existing_user.update_attribute('external', true) saml_user.save expect(gl_user).to be_valid @@ -94,14 +94,14 @@ describe Gitlab::Saml::User, lib: true do context 'with allow_single_sign_on default (["saml"])' do before { stub_omniauth_config(allow_single_sign_on: ['saml']) } - it 'should not throw an error' do + it 'does not throw an error' do expect{ saml_user.save }.not_to raise_error end end context 'with allow_single_sign_on disabled' do before { stub_omniauth_config(allow_single_sign_on: false) } - it 'should throw an error' do + it 'throws an error' do expect{ saml_user.save }.to raise_error StandardError end end @@ -223,7 +223,7 @@ describe Gitlab::Saml::User, lib: true do context 'dont block on create' do before { stub_omniauth_config(block_auto_created_users: false) } - it 'should not block the user' do + it 'does not block the user' do saml_user.save expect(gl_user).to be_valid expect(gl_user).not_to be_blocked @@ -233,7 +233,7 @@ describe Gitlab::Saml::User, lib: true do context 'block on create' do before { stub_omniauth_config(block_auto_created_users: true) } - it 'should block user' do + it 'blocks user' do saml_user.save expect(gl_user).to be_valid expect(gl_user).to be_blocked diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 1bb444bf34f..dfbefad6367 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -12,12 +12,6 @@ describe Gitlab::SearchResults do let!(:milestone) { create(:milestone, project: project, title: 'foo') } let(:results) { described_class.new(user, Project.all, 'foo') } - describe '#total_count' do - it 'returns the total amount of search hits' do - expect(results.total_count).to eq(4) - end - end - describe '#projects_count' do it 'returns the total amount of projects' do expect(results.projects_count).to eq(1) @@ -42,18 +36,6 @@ describe Gitlab::SearchResults do end end - describe '#empty?' do - it 'returns true when there are no search results' do - allow(results).to receive(:total_count).and_return(0) - - expect(results.empty?).to eq(true) - end - - it 'returns false when there are search results' do - expect(results.empty?).to eq(false) - end - end - describe 'confidential issues' do let(:project_1) { create(:empty_project) } let(:project_2) { create(:empty_project) } @@ -73,7 +55,7 @@ describe Gitlab::SearchResults do let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } - it 'should not list confidential issues for non project members' do + it 'does not list confidential issues for non project members' do results = described_class.new(non_member, limit_projects, query) issues = results.objects('issues') @@ -86,7 +68,7 @@ describe Gitlab::SearchResults do expect(results.issues_count).to eq 1 end - it 'should not list confidential issues for project members with guest role' do + it 'does not list confidential issues for project members with guest role' do project_1.team << [member, :guest] project_2.team << [member, :guest] @@ -102,7 +84,7 @@ describe Gitlab::SearchResults do expect(results.issues_count).to eq 1 end - it 'should list confidential issues for author' do + it 'lists confidential issues for author' do results = described_class.new(author, limit_projects, query) issues = results.objects('issues') @@ -115,7 +97,7 @@ describe Gitlab::SearchResults do expect(results.issues_count).to eq 3 end - it 'should list confidential issues for assignee' do + it 'lists confidential issues for assignee' do results = described_class.new(assignee, limit_projects, query) issues = results.objects('issues') @@ -128,7 +110,7 @@ describe Gitlab::SearchResults do expect(results.issues_count).to eq 3 end - it 'should list confidential issues for project members' do + it 'lists confidential issues for project members' do project_1.team << [member, :developer] project_2.team << [member, :developer] @@ -144,7 +126,7 @@ describe Gitlab::SearchResults do expect(results.issues_count).to eq 4 end - it 'should list all issues for admin' do + it 'lists all issues for admin' do results = described_class.new(admin, limit_projects, query) issues = results.objects('issues') diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb new file mode 100644 index 00000000000..c9c2f314e57 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb @@ -0,0 +1,173 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::CommandDefinition do + subject { described_class.new(:command) } + + describe "#all_names" do + context "when the command has aliases" do + before do + subject.aliases = [:alias1, :alias2] + end + + it "returns an array with the name and aliases" do + expect(subject.all_names).to eq([:command, :alias1, :alias2]) + end + end + + context "when the command doesn't have aliases" do + it "returns an array with the name" do + expect(subject.all_names).to eq([:command]) + end + end + end + + describe "#noop?" do + context "when the command has an action block" do + before do + subject.action_block = proc { } + end + + it "returns false" do + expect(subject.noop?).to be false + end + end + + context "when the command doesn't have an action block" do + it "returns true" do + expect(subject.noop?).to be true + end + end + end + + describe "#available?" do + let(:opts) { { go: false } } + + context "when the command has a condition block" do + before do + subject.condition_block = proc { go } + end + + context "when the condition block returns true" do + before do + opts[:go] = true + end + + it "returns true" do + expect(subject.available?(opts)).to be true + end + end + + context "when the condition block returns false" do + it "returns false" do + expect(subject.available?(opts)).to be false + end + end + end + + context "when the command doesn't have a condition block" do + it "returns true" do + expect(subject.available?(opts)).to be true + end + end + end + + describe "#execute" do + let(:context) { OpenStruct.new(run: false) } + + context "when the command is a noop" do + it "doesn't execute the command" do + expect(context).not_to receive(:instance_exec) + + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + + context "when the command is not a noop" do + before do + subject.action_block = proc { self.run = true } + end + + context "when the command is not available" do + before do + subject.condition_block = proc { false } + end + + it "doesn't execute the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + + context "when the command is available" do + context "when the commnd has no arguments" do + before do + subject.action_block = proc { self.run = true } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "executes the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be true + end + end + end + + context "when the command has 1 required argument" do + before do + subject.action_block = ->(arg) { self.run = arg } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "doesn't execute the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + end + + context "when the command has 1 optional argument" do + before do + subject.action_block = proc { |arg = nil| self.run = arg || true } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "executes the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be true + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb new file mode 100644 index 00000000000..26217a0e3b2 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Dsl do + before :all do + DummyClass = Struct.new(:project) do + include Gitlab::SlashCommands::Dsl + + desc 'A command with no args' + command :no_args, :none do + "Hello World!" + end + + params 'The first argument' + command :one_arg, :once, :first do |arg1| + arg1 + end + + desc do + "A dynamic description for #{noteable.upcase}" + end + params 'The first argument', 'The second argument' + command :two_args do |arg1, arg2| + [arg1, arg2] + end + + command :cc + + condition do + project == 'foo' + end + command :cond_action do |arg| + arg + end + end + end + + describe '.command_definitions' do + it 'returns an array with commands definitions' do + no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions + + expect(no_args_def.name).to eq(:no_args) + expect(no_args_def.aliases).to eq([:none]) + expect(no_args_def.description).to eq('A command with no args') + expect(no_args_def.params).to eq([]) + expect(no_args_def.condition_block).to be_nil + expect(no_args_def.action_block).to be_a_kind_of(Proc) + + expect(one_arg_def.name).to eq(:one_arg) + expect(one_arg_def.aliases).to eq([:once, :first]) + expect(one_arg_def.description).to eq('') + expect(one_arg_def.params).to eq(['The first argument']) + expect(one_arg_def.condition_block).to be_nil + expect(one_arg_def.action_block).to be_a_kind_of(Proc) + + expect(two_args_def.name).to eq(:two_args) + expect(two_args_def.aliases).to eq([]) + expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE') + expect(two_args_def.params).to eq(['The first argument', 'The second argument']) + expect(two_args_def.condition_block).to be_nil + expect(two_args_def.action_block).to be_a_kind_of(Proc) + + expect(cc_def.name).to eq(:cc) + expect(cc_def.aliases).to eq([]) + expect(cc_def.description).to eq('') + expect(cc_def.params).to eq([]) + expect(cc_def.condition_block).to be_nil + expect(cc_def.action_block).to be_nil + + expect(cond_action_def.name).to eq(:cond_action) + expect(cond_action_def.aliases).to eq([]) + expect(cond_action_def.description).to eq('') + expect(cond_action_def.params).to eq([]) + expect(cond_action_def.condition_block).to be_a_kind_of(Proc) + expect(cond_action_def.action_block).to be_a_kind_of(Proc) + end + end +end diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb new file mode 100644 index 00000000000..1e4954c4af8 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -0,0 +1,215 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Extractor do + let(:definitions) do + Class.new do + include Gitlab::SlashCommands::Dsl + + command(:reopen, :open) { } + command(:assign) { } + command(:labels) { } + command(:power) { } + end.command_definitions + end + + let(:extractor) { described_class.new(definitions) } + + shared_examples 'command with no argument' do + it 'extracts command' do + msg, commands = extractor.extract_commands(original_msg) + + expect(commands).to eq [['reopen']] + expect(msg).to eq final_msg + end + end + + shared_examples 'command with a single argument' do + it 'extracts command' do + msg, commands = extractor.extract_commands(original_msg) + + expect(commands).to eq [['assign', '@joe']] + expect(msg).to eq final_msg + end + end + + shared_examples 'command with multiple arguments' do + it 'extracts command' do + msg, commands = extractor.extract_commands(original_msg) + + expect(commands).to eq [['labels', '~foo ~"bar baz" label']] + expect(msg).to eq final_msg + end + end + + describe '#extract_commands' do + describe 'command with no argument' do + context 'at the start of content' do + it_behaves_like 'command with no argument' do + let(:original_msg) { "/reopen\nworld" } + let(:final_msg) { "world" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with no argument' do + let(:original_msg) { "hello\n/reopen\nworld" } + let(:final_msg) { "hello\nworld" } + end + end + + context 'in the middle of a line' do + it 'does not extract command' do + msg = "hello\nworld /reopen" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq "hello\nworld /reopen" + end + end + + context 'at the end of content' do + it_behaves_like 'command with no argument' do + let(:original_msg) { "hello\n/reopen" } + let(:final_msg) { "hello" } + end + end + end + + describe 'command with a single argument' do + context 'at the start of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "/assign @joe\nworld" } + let(:final_msg) { "world" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "hello\n/assign @joe\nworld" } + let(:final_msg) { "hello\nworld" } + end + end + + context 'in the middle of a line' do + it 'does not extract command' do + msg = "hello\nworld /assign @joe" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq "hello\nworld /assign @joe" + end + end + + context 'at the end of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "hello\n/assign @joe" } + let(:final_msg) { "hello" } + end + end + + context 'when argument is not separated with a space' do + it 'does not extract command' do + msg = "hello\n/assign@joe\nworld" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq "hello\n/assign@joe\nworld" + end + end + end + + describe 'command with multiple arguments' do + context 'at the start of content' do + it_behaves_like 'command with multiple arguments' do + let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) } + let(:final_msg) { "world" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with multiple arguments' do + let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) } + let(:final_msg) { "hello\nworld" } + end + end + + context 'in the middle of a line' do + it 'does not extract command' do + msg = %(hello\nworld /labels ~foo ~"bar baz" label) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label) + end + end + + context 'at the end of content' do + it_behaves_like 'command with multiple arguments' do + let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) } + let(:final_msg) { "hello" } + end + end + + context 'when argument is not separated with a space' do + it 'does not extract command' do + msg = %(hello\n/labels~foo ~"bar baz" label\nworld) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld) + end + end + end + + it 'extracts command with multiple arguments and various prefixes' do + msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']] + expect(msg).to eq "hello\nworld" + end + + it 'extracts multiple commands' do + msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['reopen']] + expect(msg).to eq "hello\nworld" + end + + it 'does not alter original content if no command is found' do + msg = 'Fixes #123' + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq 'Fixes #123' + end + + it 'does not extract commands inside a blockcode' do + msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" + expected = msg.delete("\r") + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + + it 'does not extract commands inside a blockquote' do + msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld" + expected = msg.delete("\r") + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + + it 'does not extract commands inside a HTML tag' do + msg = "Hello\r\n<div>\r\nThis is some text\r\n/close\r\n/assign @user\r\n</div>\r\n\r\nWorld" + expected = msg.delete("\r") + msg, commands = extractor.extract_commands(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + end +end diff --git a/spec/lib/gitlab/snippet_search_results_spec.rb b/spec/lib/gitlab/snippet_search_results_spec.rb index e86b9ef6a63..b661a894c0c 100644 --- a/spec/lib/gitlab/snippet_search_results_spec.rb +++ b/spec/lib/gitlab/snippet_search_results_spec.rb @@ -5,12 +5,6 @@ describe Gitlab::SnippetSearchResults do let(:results) { described_class.new(Snippet.all, 'foo') } - describe '#total_count' do - it 'returns the total amount of search hits' do - expect(results.total_count).to eq(2) - end - end - describe '#snippet_titles_count' do it 'returns the amount of matched snippet titles' do expect(results.snippet_titles_count).to eq(1) diff --git a/spec/lib/gitlab/template/gitignore_spec.rb b/spec/lib/gitlab/template/gitignore_template_spec.rb index bc0ec9325cc..9750a012e22 100644 --- a/spec/lib/gitlab/template/gitignore_spec.rb +++ b/spec/lib/gitlab/template/gitignore_template_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Template::Gitignore do +describe Gitlab::Template::GitignoreTemplate do subject { described_class } describe '.all' do @@ -24,7 +24,7 @@ describe Gitlab::Template::Gitignore do it 'returns the Gitignore object of a valid file' do ruby = subject.find('Ruby') - expect(ruby).to be_a Gitlab::Template::Gitignore + expect(ruby).to be_a Gitlab::Template::GitignoreTemplate expect(ruby.name).to eq('Ruby') end end diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb new file mode 100644 index 00000000000..e3b8321eda3 --- /dev/null +++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Gitlab::Template::GitlabCiYmlTemplate do + subject { described_class } + + describe '.all' do + it 'strips the gitlab-ci suffix' do + expect(subject.all.first.name).not_to end_with('.gitlab-ci.yml') + end + + it 'combines the globals and rest' do + all = subject.all.map(&:name) + + expect(all).to include('Elixir') + expect(all).to include('Docker') + expect(all).to include('Ruby') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect(subject.find('mepmep-yadida')).to be nil + end + + it 'returns the GitlabCiYml object of a valid file' do + ruby = subject.find('Ruby') + + expect(ruby).to be_a Gitlab::Template::GitlabCiYmlTemplate + expect(ruby.name).to eq('Ruby') + end + end + + describe '#content' do + it 'loads the full file' do + gitignore = subject.new(Rails.root.join('vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml')) + + expect(gitignore.name).to eq 'Ruby' + expect(gitignore.content).to start_with('#') + end + end +end diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb new file mode 100644 index 00000000000..d2d334e6413 --- /dev/null +++ b/spec/lib/gitlab/template/issue_template_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Gitlab::Template::IssueTemplate do + subject { described_class } + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:file_path_1) { '.gitlab/issue_templates/bug.md' } + let(:file_path_2) { '.gitlab/issue_templates/template_test.md' } + let(:file_path_3) { '.gitlab/issue_templates/feature_proposal.md' } + + before do + project.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false) + project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false) + end + + describe '.all' do + it 'strips the md suffix' do + expect(subject.all(project).first.name).not_to end_with('.issue_template') + end + + it 'combines the globals and rest' do + all = subject.all(project).map(&:name) + + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + it 'returns the issue object of a valid file' do + ruby = subject.find('bug', project) + + expect(ruby).to be_a Gitlab::Template::IssueTemplate + expect(ruby.name).to eq('bug') + end + end + + describe '.by_category' do + it 'return array of templates' do + all = subject.by_category('', project).map(&:name) + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + + context 'when repo is bare or empty' do + let(:empty_project) { create(:empty_project) } + before { empty_project.add_user(user, Gitlab::Access::MASTER) } + + it "returns empty array" do + templates = subject.by_category('', empty_project) + expect(templates).to be_empty + end + end + end + + describe '#content' do + it 'loads the full file' do + issue_template = subject.new('.gitlab/issue_templates/bug.md', project) + + expect(issue_template.name).to eq 'bug' + expect(issue_template.content).to eq('something valid') + end + + it 'raises error when file is not found' do + issue_template = subject.new('.gitlab/issue_templates/bugnot.md', project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + context "when repo is empty" do + let(:empty_project) { create(:empty_project) } + + before { empty_project.add_user(user, Gitlab::Access::MASTER) } + + it "raises file not found" do + issue_template = subject.new('.gitlab/issue_templates/not_existent.md', empty_project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + end + end +end diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb new file mode 100644 index 00000000000..ddf68c4cf78 --- /dev/null +++ b/spec/lib/gitlab/template/merge_request_template_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Gitlab::Template::MergeRequestTemplate do + subject { described_class } + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:file_path_1) { '.gitlab/merge_request_templates/bug.md' } + let(:file_path_2) { '.gitlab/merge_request_templates/template_test.md' } + let(:file_path_3) { '.gitlab/merge_request_templates/feature_proposal.md' } + + before do + project.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false) + project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false) + end + + describe '.all' do + it 'strips the md suffix' do + expect(subject.all(project).first.name).not_to end_with('.issue_template') + end + + it 'combines the globals and rest' do + all = subject.all(project).map(&:name) + + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + it 'returns the merge request object of a valid file' do + ruby = subject.find('bug', project) + + expect(ruby).to be_a Gitlab::Template::MergeRequestTemplate + expect(ruby.name).to eq('bug') + end + end + + describe '.by_category' do + it 'return array of templates' do + all = subject.by_category('', project).map(&:name) + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + + context 'when repo is bare or empty' do + let(:empty_project) { create(:empty_project) } + before { empty_project.add_user(user, Gitlab::Access::MASTER) } + + it "returns empty array" do + templates = subject.by_category('', empty_project) + expect(templates).to be_empty + end + end + end + + describe '#content' do + it 'loads the full file' do + issue_template = subject.new('.gitlab/merge_request_templates/bug.md', project) + + expect(issue_template.name).to eq 'bug' + expect(issue_template.content).to eq('something valid') + end + + it 'raises error when file is not found' do + issue_template = subject.new('.gitlab/merge_request_templates/bugnot.md', project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + context "when repo is empty" do + let(:empty_project) { create(:empty_project) } + + before { empty_project.add_user(user, Gitlab::Access::MASTER) } + + it "raises file not found" do + issue_template = subject.new('.gitlab/merge_request_templates/not_existent.md', empty_project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + end + end +end diff --git a/spec/lib/gitlab/upgrader_spec.rb b/spec/lib/gitlab/upgrader_spec.rb index e958e087a80..edadab043d7 100644 --- a/spec/lib/gitlab/upgrader_spec.rb +++ b/spec/lib/gitlab/upgrader_spec.rb @@ -9,19 +9,19 @@ describe Gitlab::Upgrader, lib: true do end describe 'latest_version?' do - it 'should be true if newest version' do + it 'is true if newest version' do allow(upgrader).to receive(:latest_version_raw).and_return(current_version) expect(upgrader.latest_version?).to be_truthy end end describe 'latest_version_raw' do - it 'should be latest version for GitLab 5' do + it 'is the latest version for GitLab 5' do allow(upgrader).to receive(:current_version_raw).and_return("5.3.0") expect(upgrader.latest_version_raw).to eq("v5.4.2") end - it 'should get the latest version from tags' do + it 'gets the latest version from tags' do allow(upgrader).to receive(:fetch_git_tags).and_return([ '6f0733310546402c15d3ae6128a95052f6c8ea96 refs/tags/v7.1.1', 'facfec4b242ce151af224e20715d58e628aa5e74 refs/tags/v7.1.1^{}', diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index 5bb095366fa..d3c3b800b94 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -9,35 +9,80 @@ describe Gitlab::UserAccess, lib: true do describe 'push to none protected branch' do it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_push_to_branch?('random_branch')).to be_truthy end it 'returns true if user is a developer' do project.team << [user, :developer] + expect(access.can_push_to_branch?('random_branch')).to be_truthy end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_push_to_branch?('random_branch')).to be_falsey end end + describe 'push to empty project' do + let(:empty_project) { create(:project_empty_repo) } + let(:project_access) { Gitlab::UserAccess.new(user, project: empty_project) } + + it 'returns true if user is master' do + empty_project.team << [user, :master] + + expect(project_access.can_push_to_branch?('master')).to be_truthy + end + + it 'returns false if user is developer and project is fully protected' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) + + expect(project_access.can_push_to_branch?('master')).to be_falsey + end + + it 'returns false if user is developer and it is not allowed to push new commits but can merge into branch' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) + + expect(project_access.can_push_to_branch?('master')).to be_falsey + end + + it 'returns true if user is developer and project is unprotected' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) + + expect(project_access.can_push_to_branch?('master')).to be_truthy + end + + it 'returns true if user is developer and project grants developers permission' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + expect(project_access.can_push_to_branch?('master')).to be_truthy + end + end + describe 'push to protected branch' do let(:branch) { create :protected_branch, project: project } it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_push_to_branch?(branch.name)).to be_truthy end it 'returns false if user is a developer' do project.team << [user, :developer] + expect(access.can_push_to_branch?(branch.name)).to be_falsey end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_push_to_branch?(branch.name)).to be_falsey end end @@ -49,16 +94,19 @@ describe Gitlab::UserAccess, lib: true do it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_push_to_branch?(@branch.name)).to be_truthy end it 'returns true if user is a developer' do project.team << [user, :developer] + expect(access.can_push_to_branch?(@branch.name)).to be_truthy end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_push_to_branch?(@branch.name)).to be_falsey end end @@ -70,19 +118,21 @@ describe Gitlab::UserAccess, lib: true do it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_merge_to_branch?(@branch.name)).to be_truthy end it 'returns true if user is a developer' do project.team << [user, :developer] + expect(access.can_merge_to_branch?(@branch.name)).to be_truthy end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_merge_to_branch?(@branch.name)).to be_falsey end end - end end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index c5c1402e8fc..b5b685da904 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -1,18 +1,141 @@ require 'spec_helper' describe Gitlab::Workhorse, lib: true do - let(:project) { create(:project) } - let(:subject) { Gitlab::Workhorse } + let(:project) { create(:project) } + let(:repository) { project.repository } - describe "#send_git_archive" do + def decode_workhorse_header(array) + key, value = array + command, encoded_params = value.split(":") + params = JSON.parse(Base64.urlsafe_decode64(encoded_params)) + + [key, command, params] + end + + describe ".send_git_archive" do context "when the repository doesn't have an archive file path" do before do allow(project.repository).to receive(:archive_metadata).and_return(Hash.new) end it "raises an error" do - expect { subject.send_git_archive(project.repository, ref: "master", format: "zip") }.to raise_error(RuntimeError) + expect { described_class.send_git_archive(project.repository, ref: "master", format: "zip") }.to raise_error(RuntimeError) + end + end + end + + describe '.send_git_patch' do + let(:diff_refs) { double(base_sha: "base", head_sha: "head") } + subject { described_class.send_git_patch(repository, diff_refs) } + + it 'sets the header correctly' do + key, command, params = decode_workhorse_header(subject) + + expect(key).to eq("Gitlab-Workhorse-Send-Data") + expect(command).to eq("git-format-patch") + expect(params).to eq("RepoPath" => repository.path_to_repo, "ShaFrom" => "base", "ShaTo" => "head") + end + end + + describe '.send_git_diff' do + let(:diff_refs) { double(base_sha: "base", head_sha: "head") } + subject { described_class.send_git_patch(repository, diff_refs) } + + it 'sets the header correctly' do + key, command, params = decode_workhorse_header(subject) + + expect(key).to eq("Gitlab-Workhorse-Send-Data") + expect(command).to eq("git-format-patch") + expect(params).to eq("RepoPath" => repository.path_to_repo, "ShaFrom" => "base", "ShaTo" => "head") + end + end + + describe ".secret" do + subject { described_class.secret } + + before do + described_class.instance_variable_set(:@secret, nil) + described_class.write_secret + end + + it 'returns 32 bytes' do + expect(subject).to be_a(String) + expect(subject.length).to eq(32) + expect(subject.encoding).to eq(Encoding::ASCII_8BIT) + end + + it 'accepts a trailing newline' do + open(described_class.secret_path, 'a') { |f| f.write "\n" } + expect(subject.length).to eq(32) + end + + it 'raises an exception if the secret file cannot be read' do + File.delete(described_class.secret_path) + expect { subject }.to raise_exception(Errno::ENOENT) + end + + it 'raises an exception if the secret file contains the wrong number of bytes' do + File.truncate(described_class.secret_path, 0) + expect { subject }.to raise_exception(RuntimeError) + end + end + + describe ".write_secret" do + let(:secret_path) { described_class.secret_path } + before do + begin + File.delete(secret_path) + rescue Errno::ENOENT end + + described_class.write_secret + end + + it 'uses mode 0600' do + expect(File.stat(secret_path).mode & 0777).to eq(0600) + end + + it 'writes base64 data' do + bytes = Base64.strict_decode64(File.read(secret_path)) + expect(bytes).not_to be_empty + end + end + + describe '#verify_api_request!' do + let(:header_key) { described_class::INTERNAL_API_REQUEST_HEADER } + let(:payload) { { 'iss' => 'gitlab-workhorse' } } + + it 'accepts a correct header' do + headers = { header_key => JWT.encode(payload, described_class.secret, 'HS256') } + expect { call_verify(headers) }.not_to raise_error + end + + it 'raises an error when the header is not set' do + expect { call_verify({}) }.to raise_jwt_error + end + + it 'raises an error when the header is not signed' do + headers = { header_key => JWT.encode(payload, nil, 'none') } + expect { call_verify(headers) }.to raise_jwt_error + end + + it 'raises an error when the header is signed with the wrong key' do + headers = { header_key => JWT.encode(payload, 'wrongkey', 'HS256') } + expect { call_verify(headers) }.to raise_jwt_error + end + + it 'raises an error when the issuer is incorrect' do + payload['iss'] = 'somebody else' + headers = { header_key => JWT.encode(payload, described_class.secret, 'HS256') } + expect { call_verify(headers) }.to raise_jwt_error + end + + def raise_jwt_error + raise_error(JWT::DecodeError) + end + + def call_verify(headers) + described_class.verify_api_request!(headers) end end end diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb new file mode 100644 index 00000000000..4d3811af254 --- /dev/null +++ b/spec/mailers/emails/merge_requests_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' +require 'email_spec' +require 'mailers/shared/notify' + +describe Notify, "merge request notifications" do + include EmailSpec::Matchers + + describe "#resolved_all_discussions_email" do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:current_user) { create(:user) } + + subject { Notify.resolved_all_discussions_email(user.id, merge_request.id, current_user.id) } + + it "includes the name of the resolver" do + expect(subject).to have_body_text current_user.name + end + end +end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index c6758ccad39..781472d0c00 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -48,7 +48,7 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like 'a user cannot unsubscribe through footer link' - it 'should not contain the new user\'s password' do + it 'does not contain the new user\'s password' do is_expected.not_to have_body_text /password/ end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index e2866ef160c..c8207e58e90 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -402,7 +402,7 @@ describe Notify do describe 'project access requested' do context 'for a project in a user namespace' do - let(:project) { create(:project).tap { |p| p.team << [p.owner, :master, p.owner] } } + let(:project) { create(:project, :public).tap { |p| p.team << [p.owner, :master, p.owner] } } let(:user) { create(:user) } let(:project_member) do project.request_access(user) @@ -429,7 +429,7 @@ describe Notify do context 'for a project in a group' do let(:group_owner) { create(:user) } let(:group) { create(:group).tap { |g| g.add_owner(group_owner) } } - let(:project) { create(:project, namespace: group) } + let(:project) { create(:project, :public, namespace: group) } let(:user) { create(:user) } let(:project_member) do project.request_access(user) @@ -492,16 +492,22 @@ describe Notify do end end - def invite_to_project(project:, email:, inviter:) - ProjectMember.add_user(project.project_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) - - project.project_members.invite.last + def invite_to_project(project, inviter:) + create( + :project_member, + :developer, + project: project, + invite_token: '1234', + invite_email: 'toto@example.com', + user: nil, + created_by: inviter + ) end describe 'project invitation' do let(:project) { create(:project) } let(:master) { create(:user).tap { |u| project.team << [u, :master] } } - let(:project_member) { invite_to_project(project: project, email: 'toto@example.com', inviter: master) } + let(:project_member) { invite_to_project(project, inviter: master) } subject { Notify.member_invited_email('project', project_member.id, project_member.invite_token) } @@ -520,10 +526,10 @@ describe Notify do describe 'project invitation accepted' do let(:project) { create(:project) } - let(:invited_user) { create(:user) } + let(:invited_user) { create(:user, name: 'invited user') } let(:master) { create(:user).tap { |u| project.team << [u, :master] } } let(:project_member) do - invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee = invite_to_project(project, inviter: master) invitee.accept_invite!(invited_user) invitee end @@ -547,7 +553,7 @@ describe Notify do let(:project) { create(:project) } let(:master) { create(:user).tap { |u| project.team << [u, :master] } } let(:project_member) do - invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee = invite_to_project(project, inviter: master) invitee.decline_invite! invitee end @@ -591,7 +597,7 @@ describe Notify do is_expected.to have_body_text /#{note.note}/ end - it 'not contains note author' do + it 'does not contain note author' do is_expected.not_to have_body_text /wrote\:/ end @@ -622,7 +628,7 @@ describe Notify do it_behaves_like 'a user cannot unsubscribe through footer link' it 'has the correct subject' do - is_expected.to have_subject /#{commit.title} \(#{commit.short_id}\)/ + is_expected.to have_subject /Re: #{project.name} | #{commit.title} \(#{commit.short_id}\)/ end it 'contains a link to the commit' do @@ -739,16 +745,22 @@ describe Notify do end end - def invite_to_group(group:, email:, inviter:) - GroupMember.add_user(group.group_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) - - group.group_members.invite.last + def invite_to_group(group, inviter:) + create( + :group_member, + :developer, + group: group, + invite_token: '1234', + invite_email: 'toto@example.com', + user: nil, + created_by: inviter + ) end describe 'group invitation' do let(:group) { create(:group) } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } - let(:group_member) { invite_to_group(group: group, email: 'toto@example.com', inviter: owner) } + let(:group_member) { invite_to_group(group, inviter: owner) } subject { Notify.member_invited_email('group', group_member.id, group_member.invite_token) } @@ -767,10 +779,10 @@ describe Notify do describe 'group invitation accepted' do let(:group) { create(:group) } - let(:invited_user) { create(:user) } + let(:invited_user) { create(:user, name: 'invited user') } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:group_member) do - invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee = invite_to_group(group, inviter: owner) invitee.accept_invite!(invited_user) invitee end @@ -794,7 +806,7 @@ describe Notify do let(:group) { create(:group) } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:group_member) do - invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee = invite_to_group(group, inviter: owner) invitee.decline_invite! invitee end @@ -819,6 +831,7 @@ describe Notify do let(:user) { create(:user, email: 'old-email@mail.com') } before do + stub_config_setting(email_subject_suffix: 'A Nice Suffix') perform_enqueued_jobs do user.email = "new-email@mail.com" user.save @@ -835,7 +848,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject "Confirmation instructions" + is_expected.to have_subject /^Confirmation instructions/ end it 'includes a link to the site' do @@ -851,7 +864,7 @@ describe Notify do subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :create) } it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'a user cannot unsubscribe through footer link' it_behaves_like 'an email with X-GitLab headers containing project details' it_behaves_like 'an email that contains a header with author username' @@ -904,7 +917,7 @@ describe Notify do subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) } it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'a user cannot unsubscribe through footer link' it_behaves_like 'an email with X-GitLab headers containing project details' it_behaves_like 'an email that contains a header with author username' @@ -926,7 +939,7 @@ describe Notify do subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) } it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'a user cannot unsubscribe through footer link' it_behaves_like 'an email with X-GitLab headers containing project details' it_behaves_like 'an email that contains a header with author username' @@ -954,7 +967,7 @@ describe Notify do subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, reverse_compare: false, diff_refs: diff_refs, send_from_committer_email: send_from_committer_email) } it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'a user cannot unsubscribe through footer link' it_behaves_like 'an email with X-GitLab headers containing project details' it_behaves_like 'an email that contains a header with author username' @@ -1056,7 +1069,7 @@ describe Notify do subject { Notify.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :push, compare: compare, diff_refs: diff_refs) } it_behaves_like 'it should show Gmail Actions View Commit link' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'a user cannot unsubscribe through footer link' it_behaves_like 'an email with X-GitLab headers containing project details' it_behaves_like 'an email that contains a header with author username' diff --git a/spec/mailers/shared/notify.rb b/spec/mailers/shared/notify.rb index 93de5850ba2..3956d05060b 100644 --- a/spec/mailers/shared/notify.rb +++ b/spec/mailers/shared/notify.rb @@ -37,6 +37,16 @@ shared_examples 'an email sent from GitLab' do reply_to = subject.header[:reply_to].addresses expect(reply_to).to eq([gitlab_sender_reply_to]) end + + context 'when custom suffix for email subject is set' do + before do + stub_config_setting(email_subject_suffix: 'A Nice Suffix') + end + + it 'ends the subject with the suffix' do + is_expected.to have_subject /\ \| A Nice Suffix$/ + end + end end shared_examples 'an email that contains a header with author username' do @@ -169,10 +179,19 @@ shared_examples 'it should show Gmail Actions View Commit link' do end shared_examples 'an unsubscribeable thread' do + it 'has a List-Unsubscribe header in the correct format' do + is_expected.to have_header 'List-Unsubscribe', /unsubscribe/ + is_expected.to have_header 'List-Unsubscribe', /^<.+>$/ + end + it { is_expected.to have_body_text /unsubscribe/ } end shared_examples 'a user cannot unsubscribe through footer link' do + it 'does not have a List-Unsubscribe header' do + is_expected.not_to have_header 'List-Unsubscribe', /unsubscribe/ + end + it { is_expected.not_to have_body_text /unsubscribe/ } end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 853f6943cef..1bdf005c823 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -218,4 +218,17 @@ describe Ability, lib: true do end end end + + describe '.project_disabled_features_rules' do + let(:project) { create(:project, wiki_access_level: ProjectFeature::DISABLED) } + + subject { described_class.allowed(project.owner, project) } + + context 'wiki named abilities' do + it 'disables wiki abilities if the project has no wiki' do + expect(project).to receive(:has_external_wiki?).and_return(false) + expect(subject).not_to include(:read_wiki, :create_wiki, :update_wiki, :admin_wiki) + end + end + end end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 305f8bc88cc..c4486a32082 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -9,6 +9,10 @@ RSpec.describe AbuseReport, type: :model do describe 'associations' do it { is_expected.to belong_to(:reporter).class_name('User') } it { is_expected.to belong_to(:user) } + + it "aliases reporter to author" do + expect(subject.author).to be(subject.reporter) + end end describe 'validations' do diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb index c5658bd26e1..0b72a2f979b 100644 --- a/spec/models/appearance_spec.rb +++ b/spec/models/appearance_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe Appearance, type: :model do - subject { create(:appearance) } + subject { build(:appearance) } it { is_expected.to be_valid } diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index fb040ba82bc..cc215d252f9 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -53,59 +53,59 @@ describe ApplicationSetting, models: true do end context 'restricted signup domains' do - it 'set single domain' do + it 'sets single domain' do setting.domain_whitelist_raw = 'example.com' expect(setting.domain_whitelist).to eq(['example.com']) end - it 'set multiple domains with spaces' do + it 'sets multiple domains with spaces' do setting.domain_whitelist_raw = 'example.com *.example.com' expect(setting.domain_whitelist).to eq(['example.com', '*.example.com']) end - it 'set multiple domains with newlines and a space' do + it 'sets multiple domains with newlines and a space' do setting.domain_whitelist_raw = "example.com\n *.example.com" expect(setting.domain_whitelist).to eq(['example.com', '*.example.com']) end - it 'set multiple domains with commas' do + it 'sets multiple domains with commas' do setting.domain_whitelist_raw = "example.com, *.example.com" expect(setting.domain_whitelist).to eq(['example.com', '*.example.com']) end end context 'blacklisted signup domains' do - it 'set single domain' do + it 'sets single domain' do setting.domain_blacklist_raw = 'example.com' expect(setting.domain_blacklist).to contain_exactly('example.com') end - it 'set multiple domains with spaces' do + it 'sets multiple domains with spaces' do setting.domain_blacklist_raw = 'example.com *.example.com' expect(setting.domain_blacklist).to contain_exactly('example.com', '*.example.com') end - it 'set multiple domains with newlines and a space' do + it 'sets multiple domains with newlines and a space' do setting.domain_blacklist_raw = "example.com\n *.example.com" expect(setting.domain_blacklist).to contain_exactly('example.com', '*.example.com') end - it 'set multiple domains with commas' do + it 'sets multiple domains with commas' do setting.domain_blacklist_raw = "example.com, *.example.com" expect(setting.domain_blacklist).to contain_exactly('example.com', '*.example.com') end - it 'set multiple domains with semicolon' do + it 'sets multiple domains with semicolon' do setting.domain_blacklist_raw = "example.com; *.example.com" expect(setting.domain_blacklist).to contain_exactly('example.com', '*.example.com') end - it 'set multiple domains with mixture of everything' do + it 'sets multiple domains with mixture of everything' do setting.domain_blacklist_raw = "example.com; *.example.com\n test.com\sblock.com yes.com" expect(setting.domain_blacklist).to contain_exactly('example.com', '*.example.com', 'test.com', 'block.com', 'yes.com') end - it 'set multiple domain with file' do + it 'sets multiple domain with file' do setting.domain_blacklist_file = File.open(Rails.root.join('spec/fixtures/', 'domain_blacklist.txt')) expect(setting.domain_blacklist).to contain_exactly('example.com', 'test.com', 'foo.bar') end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 1e5d6a34f83..03d02b4d382 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -1,3 +1,4 @@ +# encoding: utf-8 require 'rails_helper' describe Blob do @@ -7,6 +8,25 @@ describe Blob do end end + describe '#data' do + context 'using a binary blob' do + it 'returns the data as-is' do + data = "\n\xFF\xB9\xC3" + blob = described_class.new(double(binary?: true, data: data)) + + expect(blob.data).to eq(data) + end + end + + context 'using a text blob' do + it 'converts the data to UTF-8' do + blob = described_class.new(double(binary?: false, data: "\n\xFF\xB9\xC3")) + + expect(blob.data).to eq("\n���") + end + end + end + describe '#svg?' do it 'is falsey when not text' do git_blob = double(text?: false) @@ -94,4 +114,26 @@ describe Blob do expect(blob.to_partial_path).to eq 'download' end end + + describe '#size_within_svg_limits?' do + let(:blob) { described_class.decorate(double(:blob)) } + + it 'returns true when the blob size is smaller than the SVG limit' do + expect(blob).to receive(:size).and_return(42) + + expect(blob.size_within_svg_limits?).to eq(true) + end + + it 'returns true when the blob size is equal to the SVG limit' do + expect(blob).to receive(:size).and_return(Blob::MAXIMUM_SVG_SIZE) + + expect(blob.size_within_svg_limits?).to eq(true) + end + + it 'returns false when the blob size is larger than the SVG limit' do + expect(blob).to receive(:size).and_return(1.terabyte) + + expect(blob.size_within_svg_limits?).to eq(false) + end + end end diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb new file mode 100644 index 00000000000..12d29540137 --- /dev/null +++ b/spec/models/board_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +describe Board do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + end +end diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb index 6ad8bfef4f2..02d6263094a 100644 --- a/spec/models/broadcast_message_spec.rb +++ b/spec/models/broadcast_message_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' describe BroadcastMessage, models: true do - include ActiveSupport::Testing::TimeHelpers - subject { create(:broadcast_message) } it { is_expected.to be_valid } @@ -23,19 +21,19 @@ describe BroadcastMessage, models: true do end describe '.current' do - it "should return last message if time match" do + it "returns last message if time match" do message = create(:broadcast_message) expect(BroadcastMessage.current).to eq message end - it "should return nil if time not come" do + it "returns nil if time not come" do create(:broadcast_message, :future) expect(BroadcastMessage.current).to be_nil end - it "should return nil if time has passed" do + it "returns nil if time has passed" do create(:broadcast_message, :expired) expect(BroadcastMessage.current).to be_nil diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index dc88697199b..ae185de9ca3 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -32,17 +32,17 @@ describe Ci::Build, models: true do end let(:create_from_build) { Ci::Build.create_from build } - it 'there should be a pending task' do + 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 '#ignored?' do - subject { build.ignored? } + describe '#failed_but_allowed?' do + subject { build.failed_but_allowed? } - context 'if build is not allowed to fail' do + context 'when build is not allowed to fail' do before do build.allow_failure = false end @@ -64,7 +64,7 @@ describe Ci::Build, models: true do end end - context 'if build is allowed to fail' do + context 'when build is allowed to fail' do before do build.allow_failure = true end @@ -88,26 +88,88 @@ describe Ci::Build, models: true do end describe '#trace' do - subject { build.trace_html } - - it { is_expected.to be_empty } + it { expect(build.trace).to be_nil } - context 'if build.trace contains text' do + context 'when build.trace contains text' do let(:text) { 'example output' } before do build.trace = text end - it { is_expected.to include(text) } - it { expect(subject.length).to be >= text.length } + 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 'if build.trace hides token' do + context 'when build.trace hides build token' do let(:token) { 'my_secret_token' } before do - build.project.update_attributes(runners_token: token) - build.update_attributes(trace: token) + 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) } @@ -231,6 +293,34 @@ describe Ci::Build, models: true do 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 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 } @@ -283,13 +373,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq(predefined_variables) } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -301,7 +391,7 @@ describe Ci::Build, models: true do it { is_expected.to eq(predefined_variables) } end - context 'if config has variables' do + context 'when config has variables' do let(:config) do YAML.dump({ test: { @@ -393,7 +483,7 @@ describe Ci::Build, models: true do it { is_expected.to be_falsey } end - context 'if there are runner' do + context 'when there are runners' do let(:runner) { create(:ci_runner) } before do @@ -423,29 +513,27 @@ describe Ci::Build, models: true do describe '#stuck?' do subject { build.stuck? } - %w(pending).each do |state| - context "if commit_status.status is #{state}" do - before do - build.status = state - end - - it { is_expected.to be_truthy } + context "when commit_status.status is pending" do + before do + build.status = 'pending' + end - context "and there are specific runner" do - let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } + it { is_expected.to be_truthy } - before do - build.project.runners << runner - runner.save - end + context "and there are specific runner" do + let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } - it { is_expected.to be_falsey } + 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 "if commit_status.status is #{state}" do + %w[success failed canceled running].each do |state| + context "when commit_status.status is #{state}" do before do build.status = state end @@ -573,19 +661,19 @@ describe Ci::Build, models: true do 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 'to have no dependents if this is first build' do + it 'expects to have no dependents if this is first build' do expect(build.depends_on_builds).to be_empty end - it 'to have one dependent if this is test' do + 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 'to have all builds from build and test stage if this is last' do + 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 'to have retried builds instead the original ones' do + 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 @@ -655,23 +743,23 @@ describe Ci::Build, models: true do describe 'build erasable' do shared_examples 'erasable' do - it 'should remove artifact file' do + it 'removes artifact file' do expect(build.artifacts_file.exists?).to be_falsy end - it 'should remove artifact metadata file' do + it 'removes artifact metadata file' do expect(build.artifacts_metadata.exists?).to be_falsy end - it 'should erase build trace in trace file' do + it 'erases build trace in trace file' do expect(build.trace).to be_empty end - it 'should set erased to true' do + it 'sets erased to true' do expect(build.erased?).to be true end - it 'should set erase date' do + it 'sets erase date' do expect(build.erased_at).not_to be_falsy end end @@ -704,7 +792,7 @@ describe Ci::Build, models: true do include_examples 'erasable' - it 'should record user who erased a build' do + it 'records user who erased a build' do expect(build.erased_by).to eq user end end @@ -714,7 +802,7 @@ describe Ci::Build, models: true do include_examples 'erasable' - it 'should not set user who erased a build' do + it 'does not set user who erased a build' do expect(build.erased_by).to be_nil end end @@ -750,7 +838,7 @@ describe Ci::Build, models: true do end describe '#erase' do - it 'should not raise error' do + it 'does not raise error' do expect { build.erase }.not_to raise_error end end @@ -764,6 +852,53 @@ describe Ci::Build, models: true do 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 do @@ -834,13 +969,15 @@ describe Ci::Build, models: true do subject { build.play } - it 'enques a build' do + it 'enqueues a build' do is_expected.to be_pending is_expected.to eq(build) end - context 'for success build' do - before { build.queue } + context 'for successful build' do + before do + build.update(status: 'success') + end it 'creates a new build' do is_expected.to be_pending @@ -852,7 +989,7 @@ describe Ci::Build, models: true do describe '#when' do subject { build.when } - context 'if is undefined' do + context 'when `when` is undefined' do before do build.when = nil end @@ -862,13 +999,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq('on_success') } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -880,7 +1017,7 @@ describe Ci::Build, models: true do it { is_expected.to eq('on_success') } end - context 'if config has when' do + context 'when config has when' do let(:config) do YAML.dump({ test: { @@ -900,16 +1037,18 @@ describe Ci::Build, models: true do context 'when build is running' do before { build.run! } - it 'should return false' do - expect(build.retryable?).to be false + it 'returns false' do + expect(build).not_to be_retryable end end context 'when build is finished' do - before { build.success! } + before do + build.success! + end - it 'should return true' do - expect(build.retryable?).to be true + it 'returns true' do + expect(build).to be_retryable end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 36d10636ae9..a37a00f461a 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -8,7 +8,7 @@ describe Ci::Build, models: true do it 'obfuscates project runners token' do allow(build).to receive(:raw_trace).and_return("Test: #{build.project.runners_token}") - expect(build.trace).to eq("Test: xxxxxx") + expect(build.trace).to eq("Test: xxxxxxxxxxxxxxxxxxxx") end it 'empty project runners token' do @@ -19,4 +19,64 @@ describe Ci::Build, models: true do expect(build.trace).to eq(test_trace) end end + + describe '#has_trace_file?' do + context 'when there is no trace' do + it { expect(build.has_trace_file?).to be_falsey } + it { expect(build.trace).to be_nil } + end + + context 'when there is a trace' do + context 'when trace is stored in file' do + let(:build_with_trace) { create(:ci_build, :trace) } + + it { expect(build_with_trace.has_trace_file?).to be_truthy } + it { expect(build_with_trace.trace).to eq('BUILD TRACE') } + end + + context 'when trace is stored in old file' do + before do + allow(build.project).to receive(:ci_id).and_return(999) + allow(File).to receive(:exist?).with(build.path_to_trace).and_return(false) + allow(File).to receive(:exist?).with(build.old_path_to_trace).and_return(true) + allow(File).to receive(:read).with(build.old_path_to_trace).and_return(test_trace) + end + + it { expect(build.has_trace_file?).to be_truthy } + it { expect(build.trace).to eq(test_trace) } + end + + context 'when trace is stored in DB' do + before do + allow(build.project).to receive(:ci_id).and_return(nil) + allow(build).to receive(:read_attribute).with(:trace).and_return(test_trace) + allow(File).to receive(:exist?).with(build.path_to_trace).and_return(false) + allow(File).to receive(:exist?).with(build.old_path_to_trace).and_return(false) + end + + it { expect(build.has_trace_file?).to be_falsey } + it { expect(build.trace).to eq(test_trace) } + end + end + end + + describe '#trace_file_path' do + context 'when trace is stored in file' do + before do + allow(build).to receive(:has_trace_file?).and_return(true) + allow(build).to receive(:has_old_trace_file?).and_return(false) + end + + it { expect(build.trace_file_path).to eq(build.path_to_trace) } + end + + context 'when trace is stored in old file' do + before do + allow(build).to receive(:has_trace_file?).and_return(true) + allow(build).to receive(:has_old_trace_file?).and_return(true) + end + + it { expect(build.trace_file_path).to eq(build.old_path_to_trace) } + end + end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 0d4c86955ce..550a890797e 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, models: true do let(:project) { FactoryGirl.create :empty_project } - let(:pipeline) { FactoryGirl.create :ci_pipeline, project: project } + let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, status: 'created', project: project } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } @@ -18,6 +18,8 @@ describe Ci::Pipeline, models: true do it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } + it { is_expected.to delegate_method(:stages).to(:statuses) } + describe '#valid_commit_sha' do context 'commit.sha can not start with 00000000' do before do @@ -38,9 +40,6 @@ describe Ci::Pipeline, models: true do it { expect(pipeline.sha).to start_with(subject) } end - describe '#create_next_builds' do - end - describe '#retried' do subject { pipeline.retried } @@ -54,312 +53,9 @@ describe Ci::Pipeline, models: true do end end - describe '#create_builds' do - let!(:pipeline) { FactoryGirl.create :ci_pipeline, project: project, ref: 'master', tag: false } - - def create_builds(trigger_request = nil) - pipeline.create_builds(nil, trigger_request) - end - - def create_next_builds - pipeline.create_next_builds(pipeline.builds.order(:id).last) - end - - it 'creates builds' do - expect(create_builds).to be_truthy - pipeline.builds.update_all(status: "success") - expect(pipeline.builds.count(:all)).to eq(2) - - expect(create_next_builds).to be_truthy - pipeline.builds.update_all(status: "success") - expect(pipeline.builds.count(:all)).to eq(4) - - expect(create_next_builds).to be_truthy - pipeline.builds.update_all(status: "success") - expect(pipeline.builds.count(:all)).to eq(5) - - expect(create_next_builds).to be_falsey - end - - context 'custom stage with first job allowed to fail' do - let(:yaml) do - { - stages: ['clean', 'test'], - clean_job: { - stage: 'clean', - allow_failure: true, - script: 'BUILD', - }, - test_job: { - stage: 'test', - script: 'TEST', - }, - } - end - - before do - stub_ci_pipeline_yaml_file(YAML.dump(yaml)) - create_builds - end - - it 'properly schedules builds' do - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:drop) - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending', 'failed') - end - end - - context 'properly creates builds when "when" is defined' do - let(:yaml) do - { - stages: ["build", "test", "test_failure", "deploy", "cleanup"], - build: { - stage: "build", - script: "BUILD", - }, - test: { - stage: "test", - script: "TEST", - }, - test_failure: { - stage: "test_failure", - script: "ON test failure", - when: "on_failure", - }, - deploy: { - stage: "deploy", - script: "PUBLISH", - }, - cleanup: { - stage: "cleanup", - script: "TIDY UP", - when: "always", - } - } - end - - before do - stub_ci_pipeline_yaml_file(YAML.dump(yaml)) - end - - context 'when builds are successful' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(pipeline.builds.pluck(:name)).to contain_exactly('build') - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'success') - pipeline.reload - expect(pipeline.status).to eq('success') - end - end - - context 'when test job fails' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(pipeline.builds.pluck(:name)).to contain_exactly('build') - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'success') - pipeline.reload - expect(pipeline.status).to eq('failed') - end - end - - context 'when test and test_failure jobs fail' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(pipeline.builds.pluck(:name)).to contain_exactly('build') - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'success') - pipeline.reload - expect(pipeline.status).to eq('failed') - end - end - - context 'when deploy job fails' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(pipeline.builds.pluck(:name)).to contain_exactly('build') - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'success') - pipeline.reload - expect(pipeline.status).to eq('failed') - end - end - - context 'when build is canceled in the second stage' do - it 'does not schedule builds after build has been canceled' do - expect(create_builds).to be_truthy - expect(pipeline.builds.pluck(:name)).to contain_exactly('build') - expect(pipeline.builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(pipeline.builds.running_or_pending).not_to be_empty - - expect(pipeline.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(pipeline.builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:cancel) - - expect(pipeline.builds.running_or_pending).to be_empty - expect(pipeline.reload.status).to eq('canceled') - end - end - - context 'when listing manual actions' do - let(:yaml) do - { - stages: ["build", "test", "staging", "production", "cleanup"], - build: { - stage: "build", - script: "BUILD", - }, - test: { - stage: "test", - script: "TEST", - }, - staging: { - stage: "staging", - script: "PUBLISH", - }, - production: { - stage: "production", - script: "PUBLISH", - when: "manual", - }, - cleanup: { - stage: "cleanup", - script: "TIDY UP", - when: "always", - }, - clear_cache: { - stage: "cleanup", - script: "CLEAR CACHE", - when: "manual", - } - } - end - - it 'returns only for skipped builds' do - # currently all builds are created - expect(create_builds).to be_truthy - expect(manual_actions).to be_empty - - # succeed stage build - pipeline.builds.running_or_pending.each(&:success) - expect(manual_actions).to be_empty - - # succeed stage test - pipeline.builds.running_or_pending.each(&:success) - expect(manual_actions).to be_empty - - # succeed stage staging and skip stage production - pipeline.builds.running_or_pending.each(&:success) - expect(manual_actions).to be_many # production and clear cache - - # succeed stage cleanup - pipeline.builds.running_or_pending.each(&:success) - - # after processing a pipeline we should have 6 builds, 5 succeeded - expect(pipeline.builds.count).to eq(6) - expect(pipeline.builds.success.count).to eq(4) - end - - def manual_actions - pipeline.manual_actions - end - end - end - - context 'when no builds created' do - let(:pipeline) { build(:ci_pipeline) } - - before do - stub_ci_pipeline_yaml_file(YAML.dump(before_script: ['ls'])) - end - - it 'returns false' do - expect(pipeline.create_builds(nil)).to be_falsey - expect(pipeline).not_to be_persisted - end - end - end - - describe "#finished_at" do - let(:pipeline) { FactoryGirl.create :ci_pipeline } - - it "returns finished_at of latest build" do - build = FactoryGirl.create :ci_build, pipeline: pipeline, finished_at: Time.now - 60 - FactoryGirl.create :ci_build, pipeline: pipeline, finished_at: Time.now - 120 - - expect(pipeline.finished_at.to_i).to eq(build.finished_at.to_i) - end - - it "returns nil if there is no finished build" do - FactoryGirl.create :ci_not_started_build, pipeline: pipeline - - expect(pipeline.finished_at).to be_nil - end - end - describe "coverage" do let(:project) { FactoryGirl.create :empty_project, build_coverage_regex: "/.*/" } - let(:pipeline) { FactoryGirl.create :ci_pipeline, project: project } + let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project } it "calculates average when there are two builds with coverage" do FactoryGirl.create :ci_build, name: "rspec", coverage: 30, pipeline: pipeline @@ -426,35 +122,109 @@ describe Ci::Pipeline, models: true do end end - describe '#update_state' do - it 'execute update_state after touching object' do - expect(pipeline).to receive(:update_state).and_return(true) - pipeline.touch + describe 'state machine' do + let(:current) { Time.now.change(usec: 0) } + let(:build) { create_build('build1', current, 10) } + let(:build_b) { create_build('build2', current, 20) } + let(:build_c) { create_build('build3', current + 50, 10) } + + describe '#duration' do + before do + pipeline.update(created_at: current) + + travel_to(current + 5) do + pipeline.run + pipeline.save + end + + travel_to(current + 30) do + build.success + end + + travel_to(current + 40) do + build_b.drop + end + + travel_to(current + 70) do + build_c.success + end + + pipeline.drop + end + + it 'matches sum of builds duration' do + pipeline.reload + + expect(pipeline.duration).to eq(40) + end end - context 'dependent objects' do - let(:commit_status) { build :commit_status, pipeline: pipeline } + describe '#started_at' do + it 'updates on transitioning to running' do + build.run - it 'execute update_state after saving dependent object' do - expect(pipeline).to receive(:update_state).and_return(true) - commit_status.save + expect(pipeline.reload.started_at).not_to be_nil + end + + it 'does not update on transitioning to success' do + build.success + + expect(pipeline.reload.started_at).to be_nil end end - context 'update state' do - let(:current) { Time.now.change(usec: 0) } - let(:build) { FactoryGirl.create :ci_build, :success, pipeline: pipeline, started_at: current - 120, finished_at: current - 60 } + describe '#finished_at' do + it 'updates on transitioning to success' do + build.success - before do - build + expect(pipeline.reload.finished_at).not_to be_nil + end + + it 'does not update on transitioning to running' do + build.run + + expect(pipeline.reload.finished_at).to be_nil + end + end + + describe "merge request metrics" do + let(:project) { FactoryGirl.create :project } + let(:pipeline) { FactoryGirl.create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: project.repository.commit('master').id) } + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: pipeline.ref) } + + context 'when transitioning to running' do + it 'records the build start time' do + time = Time.now + Timecop.freeze(time) { build.run } + + expect(merge_request.reload.metrics.latest_build_started_at).to be_within(1.second).of(time) + end + + it 'clears the build end time' do + build.run + + expect(merge_request.reload.metrics.latest_build_finished_at).to be_nil + end end - [:status, :started_at, :finished_at, :duration].each do |param| - it "update #{param}" do - expect(pipeline.send(param)).to eq(build.send(param)) + context 'when transitioning to success' do + it 'records the build end time' do + build.run + time = Time.now + Timecop.freeze(time) { build.success } + + expect(merge_request.reload.metrics.latest_build_finished_at).to be_within(1.second).of(time) end end end + + def create_build(name, queued_at = current, started_from = 0) + create(:ci_build, + name: name, + pipeline: pipeline, + queued_at: queued_at, + started_at: queued_at + started_from) + end end describe '#branch?' do @@ -481,6 +251,36 @@ describe Ci::Pipeline, models: true do end end + context 'with non-empty project' do + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + ref: project.default_branch, + sha: project.commit.sha) + end + + describe '#latest?' do + context 'with latest sha' do + it 'returns true' do + expect(pipeline).to be_latest + end + end + + context 'with not latest sha' do + before do + pipeline.update( + sha: project.commit("#{project.default_branch}~1").sha) + end + + it 'returns false' do + expect(pipeline).not_to be_latest + end + end + end + end + describe '#manual_actions' do subject { pipeline.manual_actions } @@ -513,7 +313,7 @@ describe Ci::Pipeline, models: true do create :ci_build, :success, pipeline: pipeline, name: 'rspec' create :ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop' end - + it 'returns true' do is_expected.to be_truthy end @@ -524,7 +324,7 @@ describe Ci::Pipeline, models: true do create :ci_build, :success, pipeline: pipeline, name: 'rspec' create :ci_build, :allowed_to_fail, :success, pipeline: pipeline, name: 'rubocop' end - + it 'returns false' do is_expected.to be_falsey end @@ -542,4 +342,185 @@ describe Ci::Pipeline, models: true do end end end + + describe '#status' do + let!(:build) { create(:ci_build, :created, pipeline: pipeline, name: 'test') } + + subject { pipeline.reload.status } + + context 'on queuing' do + before do + build.enqueue + end + + it { is_expected.to eq('pending') } + end + + context 'on run' do + before do + build.enqueue + build.run + end + + it { is_expected.to eq('running') } + end + + context 'on drop' do + before do + build.drop + end + + it { is_expected.to eq('failed') } + end + + context 'on success' do + before do + build.success + end + + it { is_expected.to eq('success') } + end + + context 'on cancel' do + before do + build.cancel + end + + it { is_expected.to eq('canceled') } + end + + context 'on failure and build retry' do + before do + build.drop + Ci::Build.retry(build) + end + + # We are changing a state: created > failed > running + # Instead of: created > failed > pending + # Since the pipeline already run, so it should not be pending anymore + + it { is_expected.to eq('running') } + end + end + + describe '#execute_hooks' do + let!(:build_a) { create_build('a', 0) } + let!(:build_b) { create_build('b', 1) } + + let!(:hook) do + create(:project_hook, project: project, pipeline_events: enabled) + end + + before do + ProjectWebHookWorker.drain + end + + context 'with pipeline hooks enabled' do + let(:enabled) { true } + + before do + WebMock.stub_request(:post, hook.url) + end + + context 'with multiple builds' do + context 'when build is queued' do + before do + build_a.enqueue + build_b.enqueue + end + + it 'receives a pending event once' do + expect(WebMock).to have_requested_pipeline_hook('pending').once + end + end + + context 'when build is run' do + before do + build_a.enqueue + build_a.run + build_b.enqueue + build_b.run + end + + it 'receives a running event once' do + expect(WebMock).to have_requested_pipeline_hook('running').once + end + end + + context 'when all builds succeed' do + before do + build_a.success + build_b.success + end + + it 'receives a success event once' do + expect(WebMock).to have_requested_pipeline_hook('success').once + end + end + + context 'when stage one failed' do + before do + build_a.drop + end + + it 'receives a failed event once' do + expect(WebMock).to have_requested_pipeline_hook('failed').once + end + end + + def have_requested_pipeline_hook(status) + have_requested(:post, hook.url).with do |req| + json_body = JSON.parse(req.body) + json_body['object_attributes']['status'] == status && + json_body['builds'].length == 2 + end + end + end + end + + context 'with pipeline hooks disabled' do + let(:enabled) { false } + + before do + build_a.enqueue + build_b.enqueue + end + + it 'did not execute pipeline_hook after touched' do + expect(WebMock).not_to have_requested(:post, hook.url) + end + end + + def create_build(name, stage_idx) + create(:ci_build, + :created, + pipeline: pipeline, + name: name, + stage_idx: stage_idx) + end + end + + describe "#merge_requests" do + let(:project) { FactoryGirl.create :project } + let(:pipeline) { FactoryGirl.create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: project.repository.commit('master').id) } + + it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do + merge_request = create(:merge_request, source_project: project, source_branch: pipeline.ref) + + expect(pipeline.merge_requests).to eq([merge_request]) + end + + it "doesn't return merge requests whose source branch doesn't match the pipeline's ref" do + create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') + + expect(pipeline.merge_requests).to be_empty + end + + it "doesn't return merge requests whose `diff_head_sha` doesn't match the pipeline's SHA" do + create(:merge_request, source_project: project, source_branch: pipeline.ref) + allow_any_instance_of(MergeRequest).to receive(:diff_head_sha) { '97de212e80737a608d939f648d959671fb0a0142b' } + + expect(pipeline.merge_requests).to be_empty + end + end end diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 474b0b1621d..3ca9231f58e 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -4,12 +4,12 @@ describe Ci::Trigger, models: true do let(:project) { FactoryGirl.create :empty_project } describe 'before_validation' do - it 'should set an random token if none provided' do + it 'sets an random token if none provided' do trigger = FactoryGirl.create :ci_trigger_without_token, project: project expect(trigger.token).not_to be_nil end - it 'should not set an random token if one provided' do + it 'does not set an random token if one provided' do trigger = FactoryGirl.create :ci_trigger, project: project expect(trigger.token).to eq('token') end diff --git a/spec/models/commit_range_spec.rb b/spec/models/commit_range_spec.rb index 384a38ebc69..c41359b55a3 100644 --- a/spec/models/commit_range_spec.rb +++ b/spec/models/commit_range_spec.rb @@ -76,16 +76,6 @@ describe CommitRange, models: true do end end - describe '#reference_title' do - it 'returns the correct String for three-dot ranges' do - expect(range.reference_title).to eq "Commits #{full_sha_from} through #{full_sha_to}" - end - - it 'returns the correct String for two-dot ranges' do - expect(range2.reference_title).to eq "Commits #{full_sha_from}^ through #{full_sha_to}" - end - end - describe '#to_param' do it 'includes the correct keys' do expect(range.to_param.keys).to eq %i(from to) diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index d3e6a6648cc..51be3f36135 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -164,10 +164,10 @@ eos let(:data) { commit.hook_attrs(with_changed_files: true) } it { expect(data).to be_a(Hash) } - it { expect(data[:message]).to include('Add submodule from gitlab.com') } - it { expect(data[:timestamp]).to eq('2014-02-27T11:01:38+02:00') } - it { expect(data[:added]).to eq(["gitlab-grack"]) } - it { expect(data[:modified]).to eq([".gitmodules"]) } + it { expect(data[:message]).to include('adds bar folder and branch-test text file to check Repository merged_to_root_ref method') } + it { expect(data[:timestamp]).to eq('2016-09-27T14:37:46+00:00') } + it { expect(data[:added]).to eq(["bar/branch-test.txt"]) } + it { expect(data[:modified]).to eq([]) } it { expect(data[:removed]).to eq([]) } end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index ff6371ad685..80c2a1bc7a9 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -7,7 +7,11 @@ describe CommitStatus, models: true do create(:ci_pipeline, project: project, sha: project.commit.id) end - let(:commit_status) { create(:commit_status, pipeline: pipeline) } + let(:commit_status) { create_status } + + def create_status(args = {}) + create(:commit_status, args.merge(pipeline: pipeline)) + end it { is_expected.to belong_to(:pipeline) } it { is_expected.to belong_to(:user) } @@ -40,7 +44,7 @@ describe CommitStatus, models: true do it { is_expected.to be_falsey } end - %w(running success failed).each do |status| + %w[running success failed].each do |status| context "if commit status is #{status}" do before { commit_status.status = status } @@ -48,7 +52,7 @@ describe CommitStatus, models: true do end end - %w(pending canceled).each do |status| + %w[pending canceled].each do |status| context "if commit status is #{status}" do before { commit_status.status = status } @@ -60,7 +64,7 @@ describe CommitStatus, models: true do describe '#active?' do subject { commit_status.active? } - %w(pending running).each do |state| + %w[pending running].each do |state| context "if commit_status.status is #{state}" do before { commit_status.status = state } @@ -68,7 +72,7 @@ describe CommitStatus, models: true do end end - %w(success failed canceled).each do |state| + %w[success failed canceled].each do |state| context "if commit_status.status is #{state}" do before { commit_status.status = state } @@ -80,7 +84,7 @@ describe CommitStatus, models: true do describe '#complete?' do subject { commit_status.complete? } - %w(success failed canceled).each do |state| + %w[success failed canceled].each do |state| context "if commit_status.status is #{state}" do before { commit_status.status = state } @@ -88,7 +92,7 @@ describe CommitStatus, models: true do end end - %w(pending running).each do |state| + %w[pending running].each do |state| context "if commit_status.status is #{state}" do before { commit_status.status = state } @@ -125,32 +129,53 @@ describe CommitStatus, models: true do describe '.latest' do subject { CommitStatus.latest.order(:id) } - before do - @commit1 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'aa', ref: 'bb', status: 'running' - @commit2 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'cc', ref: 'cc', status: 'pending' - @commit3 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'aa', ref: 'cc', status: 'success' - @commit4 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'cc', ref: 'bb', status: 'success' - @commit5 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'aa', ref: 'bb', status: 'success' + let(:statuses) do + [create_status(name: 'aa', ref: 'bb', status: 'running'), + create_status(name: 'cc', ref: 'cc', status: 'pending'), + create_status(name: 'aa', ref: 'cc', status: 'success'), + create_status(name: 'cc', ref: 'bb', status: 'success'), + create_status(name: 'aa', ref: 'bb', status: 'success')] end - it 'return unique statuses' do - is_expected.to eq([@commit4, @commit5]) + it 'returns unique statuses' do + is_expected.to eq(statuses.values_at(3, 4)) end end describe '.running_or_pending' do subject { CommitStatus.running_or_pending.order(:id) } - before do - @commit1 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'aa', ref: 'bb', status: 'running' - @commit2 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'cc', ref: 'cc', status: 'pending' - @commit3 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'aa', ref: nil, status: 'success' - @commit4 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'dd', ref: nil, status: 'failed' - @commit5 = FactoryGirl.create :commit_status, pipeline: pipeline, name: 'ee', ref: nil, status: 'canceled' + let(:statuses) do + [create_status(name: 'aa', ref: 'bb', status: 'running'), + create_status(name: 'cc', ref: 'cc', status: 'pending'), + create_status(name: 'aa', ref: nil, status: 'success'), + create_status(name: 'dd', ref: nil, status: 'failed'), + create_status(name: 'ee', ref: nil, status: 'canceled')] end - it 'return statuses that are running or pending' do - is_expected.to eq([@commit1, @commit2]) + it 'returns statuses that are running or pending' do + is_expected.to eq(statuses.values_at(0, 1)) + end + end + + describe '.exclude_ignored' do + subject { CommitStatus.exclude_ignored.order(:id) } + + let(:statuses) do + [create_status(when: 'manual', status: 'skipped'), + create_status(when: 'manual', status: 'success'), + create_status(when: 'manual', status: 'failed'), + create_status(when: 'on_failure', status: 'skipped'), + create_status(when: 'on_failure', status: 'success'), + create_status(when: 'on_failure', status: 'failed'), + create_status(allow_failure: true, status: 'success'), + create_status(allow_failure: true, status: 'failed'), + create_status(allow_failure: false, status: 'success'), + create_status(allow_failure: false, status: 'failed')] + end + + it 'returns statuses without what we want to ignore' do + is_expected.to eq(statuses.values_at(1, 2, 4, 5, 6, 8, 9)) end end @@ -160,7 +185,7 @@ describe CommitStatus, models: true do context 'when no before_sha is set for pipeline' do before { pipeline.before_sha = nil } - it 'return blank sha' do + it 'returns blank sha' do is_expected.to eq(Gitlab::Git::BLANK_SHA) end end @@ -169,7 +194,7 @@ describe CommitStatus, models: true do let(:value) { '1234' } before { pipeline.before_sha = value } - it 'return the set value' do + it 'returns the set value' do is_expected.to eq(value) end end @@ -186,15 +211,15 @@ describe CommitStatus, models: true do context 'stages list' do subject { CommitStatus.where(pipeline: pipeline).stages } - it 'return ordered list of stages' do - is_expected.to eq(%w(build test deploy)) + it 'returns ordered list of stages' do + is_expected.to eq(%w[build test deploy]) end end context 'stages with statuses' do subject { CommitStatus.where(pipeline: pipeline).latest.stages_status } - it 'return list of stages with statuses' do + it 'returns list of stages with statuses' do is_expected.to eq({ 'build' => 'failed', 'test' => 'success', @@ -223,4 +248,33 @@ describe CommitStatus, models: true do expect(commit_status.commit).to eq project.commit end end + + describe '#group_name' do + subject { commit_status.group_name } + + tests = { + 'rspec:windows' => 'rspec:windows', + 'rspec:windows 0' => 'rspec:windows 0', + 'rspec:windows 0 test' => 'rspec:windows 0 test', + 'rspec:windows 0 1' => 'rspec:windows', + 'rspec:windows 0 1 name' => 'rspec:windows name', + 'rspec:windows 0/1' => 'rspec:windows', + 'rspec:windows 0/1 name' => 'rspec:windows name', + 'rspec:windows 0:1' => 'rspec:windows', + 'rspec:windows 0:1 name' => 'rspec:windows name', + 'rspec:windows 10000 20000' => 'rspec:windows', + 'rspec:windows 0 : / 1' => 'rspec:windows', + 'rspec:windows 0 : / 1 name' => 'rspec:windows name', + '0 1 name ruby' => 'name ruby', + '0 :/ 1 name ruby' => 'name ruby' + } + + tests.each do |name, group_name| + it "'#{name}' puts in '#{group_name}'" do + commit_status.name = name + + is_expected.to eq(group_name) + end + end + end end diff --git a/spec/models/concerns/awardable_spec.rb b/spec/models/concerns/awardable_spec.rb index a371c4a18a9..de791abdf3d 100644 --- a/spec/models/concerns/awardable_spec.rb +++ b/spec/models/concerns/awardable_spec.rb @@ -45,4 +45,14 @@ describe Issue, "Awardable" do expect { issue.toggle_award_emoji("thumbsdown", award_emoji.user) }.to change { AwardEmoji.count }.by(-1) end end + + describe 'querying award_emoji on an Awardable' do + let(:issue) { create(:issue) } + + it 'sorts in ascending fashion' do + create_list(:award_emoji, 3, awardable: issue) + + expect(issue.award_emoji).to eq issue.award_emoji.sort_by(&:id) + end + end end diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb new file mode 100644 index 00000000000..15cd3a7ed70 --- /dev/null +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -0,0 +1,181 @@ +require 'spec_helper' + +describe CacheMarkdownField do + CacheMarkdownField::CACHING_CLASSES << "ThingWithMarkdownFields" + + # The minimum necessary ActiveModel to test this concern + class ThingWithMarkdownFields + include ActiveModel::Model + include ActiveModel::Dirty + + include ActiveModel::Serialization + + class_attribute :attribute_names + self.attribute_names = [] + + def attributes + attribute_names.each_with_object({}) do |name, hsh| + hsh[name.to_s] = send(name) + end + end + + extend ActiveModel::Callbacks + define_model_callbacks :save + + include CacheMarkdownField + cache_markdown_field :foo + cache_markdown_field :baz, pipeline: :single_line + + def self.add_attr(attr_name) + self.attribute_names += [attr_name] + define_attribute_methods(attr_name) + attr_reader(attr_name) + define_method("#{attr_name}=") do |val| + send("#{attr_name}_will_change!") unless val == send(attr_name) + instance_variable_set("@#{attr_name}", val) + end + end + + [:foo, :foo_html, :bar, :baz, :baz_html].each do |attr_name| + add_attr(attr_name) + end + + def initialize(*) + super + + # Pretend new is load + clear_changes_information + end + + def save + run_callbacks :save do + changes_applied + end + end + end + + CacheMarkdownField::CACHING_CLASSES.delete("ThingWithMarkdownFields") + + def thing_subclass(new_attr) + Class.new(ThingWithMarkdownFields) { add_attr(new_attr) } + end + + let(:markdown) { "`Foo`" } + let(:html) { "<p><code>Foo</code></p>" } + + let(:updated_markdown) { "`Bar`" } + let(:updated_html) { "<p><code>Bar</code></p>" } + + subject { ThingWithMarkdownFields.new(foo: markdown, foo_html: html) } + + describe ".attributes" do + it "excludes cache attributes" do + expect(thing_subclass(:qux).new.attributes.keys.sort).to eq(%w[bar baz foo qux]) + end + end + + describe ".cache_markdown_field" do + it "refuses to allow untracked classes" do + expect { thing_subclass(:qux).__send__(:cache_markdown_field, :qux) }.to raise_error(RuntimeError) + end + end + + context "an unchanged markdown field" do + before do + subject.foo = subject.foo + subject.save + end + + it { expect(subject.foo).to eq(markdown) } + it { expect(subject.foo_html).to eq(html) } + it { expect(subject.foo_html_changed?).not_to be_truthy } + end + + context "a changed markdown field" do + before do + subject.foo = updated_markdown + subject.save + end + + it { expect(subject.foo_html).to eq(updated_html) } + end + + context "a non-markdown field changed" do + before do + subject.bar = "OK" + subject.save + end + + it { expect(subject.bar).to eq("OK") } + it { expect(subject.foo).to eq(markdown) } + it { expect(subject.foo_html).to eq(html) } + end + + describe '#banzai_render_context' do + it "sets project to nil if the object lacks a project" do + context = subject.banzai_render_context(:foo) + expect(context).to have_key(:project) + expect(context[:project]).to be_nil + end + + it "excludes author if the object lacks an author" do + context = subject.banzai_render_context(:foo) + expect(context).not_to have_key(:author) + end + + it "raises if the context for an unrecognised field is requested" do + expect{subject.banzai_render_context(:not_found)}.to raise_error(ArgumentError) + end + + it "includes the pipeline" do + context = subject.banzai_render_context(:baz) + expect(context[:pipeline]).to eq(:single_line) + end + + it "returns copies of the context template" do + template = subject.cached_markdown_fields[:baz] + copy = subject.banzai_render_context(:baz) + expect(copy).not_to be(template) + end + + context "with a project" do + subject { thing_subclass(:project).new(foo: markdown, foo_html: html, project: :project) } + + it "sets the project in the context" do + context = subject.banzai_render_context(:foo) + expect(context).to have_key(:project) + expect(context[:project]).to eq(:project) + end + + it "invalidates the cache when project changes" do + subject.project = :new_project + allow(Banzai::Renderer).to receive(:cacheless_render_field).and_return(updated_html) + + subject.save + + expect(subject.foo_html).to eq(updated_html) + expect(subject.baz_html).to eq(updated_html) + end + end + + context "with an author" do + subject { thing_subclass(:author).new(foo: markdown, foo_html: html, author: :author) } + + it "sets the author in the context" do + context = subject.banzai_render_context(:foo) + expect(context).to have_key(:author) + expect(context[:author]).to eq(:author) + end + + it "invalidates the cache when author changes" do + subject.author = :new_author + allow(Banzai::Renderer).to receive(:cacheless_render_field).and_return(updated_html) + + subject.save + + expect(subject.foo_html).to eq(updated_html) + expect(subject.baz_html).to eq(updated_html) + end + end + end +end diff --git a/spec/models/concerns/faster_cache_keys_spec.rb b/spec/models/concerns/faster_cache_keys_spec.rb new file mode 100644 index 00000000000..8d3f94267fa --- /dev/null +++ b/spec/models/concerns/faster_cache_keys_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe FasterCacheKeys do + describe '#cache_key' do + it 'returns a String' do + # We're using a fixed string here so it's easier to set an expectation for + # the resulting cache key. + time = '2016-08-08 16:39:00+02' + issue = build(:issue, updated_at: time) + issue.extend(described_class) + + expect(issue).to receive(:id).and_return(1) + + expect(issue.cache_key).to eq("issues/1-#{time}") + end + end +end diff --git a/spec/models/concerns/statuseable_spec.rb b/spec/models/concerns/has_status_spec.rb index 8e0a2a2cbde..87bffbdc54e 100644 --- a/spec/models/concerns/statuseable_spec.rb +++ b/spec/models/concerns/has_status_spec.rb @@ -1,26 +1,17 @@ require 'spec_helper' -describe Statuseable do - before do - @object = Object.new - @object.extend(Statuseable::ClassMethods) - end - +describe HasStatus do describe '.status' do - before do - allow(@object).to receive(:all).and_return(CommitStatus.where(id: statuses)) - end + subject { CommitStatus.status } - subject { @object.status } - shared_examples 'build status summary' do context 'all successful' do - let(:statuses) { Array.new(2) { create(type, status: :success) } } + let!(:statuses) { Array.new(2) { create(type, status: :success) } } it { is_expected.to eq 'success' } end context 'at least one failed' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :failed)] end @@ -28,7 +19,7 @@ describe Statuseable do end context 'at least one running' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :running)] end @@ -36,7 +27,7 @@ describe Statuseable do end context 'at least one pending' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :pending)] end @@ -44,7 +35,7 @@ describe Statuseable do end context 'success and failed but allowed to fail' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :failed, allow_failure: true)] end @@ -53,12 +44,15 @@ describe Statuseable do end context 'one failed but allowed to fail' do - let(:statuses) { [create(type, status: :failed, allow_failure: true)] } + let!(:statuses) do + [create(type, status: :failed, allow_failure: true)] + end + it { is_expected.to eq 'success' } end context 'success and canceled' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :canceled)] end @@ -66,7 +60,7 @@ describe Statuseable do end context 'one failed and one canceled' do - let(:statuses) do + let!(:statuses) do [create(type, status: :failed), create(type, status: :canceled)] end @@ -74,7 +68,7 @@ describe Statuseable do end context 'one failed but allowed to fail and one canceled' do - let(:statuses) do + let!(:statuses) do [create(type, status: :failed, allow_failure: true), create(type, status: :canceled)] end @@ -83,7 +77,7 @@ describe Statuseable do end context 'one running one canceled' do - let(:statuses) do + let!(:statuses) do [create(type, status: :running), create(type, status: :canceled)] end @@ -91,14 +85,15 @@ describe Statuseable do end context 'all canceled' do - let(:statuses) do + let!(:statuses) do [create(type, status: :canceled), create(type, status: :canceled)] end + it { is_expected.to eq 'canceled' } end context 'success and canceled but allowed to fail' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :canceled, allow_failure: true)] end @@ -107,7 +102,7 @@ describe Statuseable do end context 'one finished and second running but allowed to fail' do - let(:statuses) do + let!(:statuses) do [create(type, status: :success), create(type, status: :running, allow_failure: true)] end @@ -118,11 +113,13 @@ describe Statuseable do context 'ci build statuses' do let(:type) { :ci_build } + it_behaves_like 'build status summary' end context 'generic commit statuses' do let(:type) { :generic_commit_status } + it_behaves_like 'build status summary' end end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 549b0042038..132858950d5 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -1,18 +1,27 @@ require 'spec_helper' describe Mentionable do - include Mentionable + class Example + include Mentionable - def author - nil + attr_accessor :project, :message + attr_mentionable :message + + def author + nil + end end describe 'references' do let(:project) { create(:project) } + let(:mentionable) { Example.new } it 'excludes JIRA references' do allow(project).to receive_messages(jira_tracker?: true) - expect(referenced_mentionables(project, 'JIRA-123')).to be_empty + + mentionable.project = project + mentionable.message = 'JIRA-123' + expect(mentionable.referenced_mentionables).to be_empty end end end @@ -39,9 +48,8 @@ describe Issue, "Mentionable" do let(:user) { create(:user) } def referenced_issues(current_user) - text = "#{private_issue.to_reference(project)} and #{public_issue.to_reference}" - - issue.referenced_mentionables(current_user, text) + issue.title = "#{private_issue.to_reference(project)} and #{public_issue.to_reference}" + issue.referenced_mentionables(current_user) end context 'when the current user can see the issue' do diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 7e9ab8940cf..b7e973798a3 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -26,53 +26,53 @@ describe Milestone, 'Milestoneish' do end describe '#closed_items_count' do - it 'should not count confidential issues for non project members' do + it 'does not count confidential issues for non project members' do expect(milestone.closed_items_count(non_member)).to eq 2 end - it 'should not count confidential issues for project members with guest role' do + it 'does not count confidential issues for project members with guest role' do expect(milestone.closed_items_count(guest)).to eq 2 end - it 'should count confidential issues for author' do + it 'counts confidential issues for author' do expect(milestone.closed_items_count(author)).to eq 4 end - it 'should count confidential issues for assignee' do + it 'counts confidential issues for assignee' do expect(milestone.closed_items_count(assignee)).to eq 4 end - it 'should count confidential issues for project members' do + it 'counts confidential issues for project members' do expect(milestone.closed_items_count(member)).to eq 6 end - it 'should count all issues for admin' do + it 'counts all issues for admin' do expect(milestone.closed_items_count(admin)).to eq 6 end end describe '#total_items_count' do - it 'should not count confidential issues for non project members' do + it 'does not count confidential issues for non project members' do expect(milestone.total_items_count(non_member)).to eq 4 end - it 'should not count confidential issues for project members with guest role' do + it 'does not count confidential issues for project members with guest role' do expect(milestone.total_items_count(guest)).to eq 4 end - it 'should count confidential issues for author' do + it 'counts confidential issues for author' do expect(milestone.total_items_count(author)).to eq 7 end - it 'should count confidential issues for assignee' do + it 'counts confidential issues for assignee' do expect(milestone.total_items_count(assignee)).to eq 7 end - it 'should count confidential issues for project members' do + it 'counts confidential issues for project members' do expect(milestone.total_items_count(member)).to eq 10 end - it 'should count all issues for admin' do + it 'counts all issues for admin' do expect(milestone.total_items_count(admin)).to eq 10 end end @@ -91,27 +91,27 @@ describe Milestone, 'Milestoneish' do end describe '#percent_complete' do - it 'should not count confidential issues for non project members' do + it 'does not count confidential issues for non project members' do expect(milestone.percent_complete(non_member)).to eq 50 end - it 'should not count confidential issues for project members with guest role' do + it 'does not count confidential issues for project members with guest role' do expect(milestone.percent_complete(guest)).to eq 50 end - it 'should count confidential issues for author' do + it 'counts confidential issues for author' do expect(milestone.percent_complete(author)).to eq 57 end - it 'should count confidential issues for assignee' do + it 'counts confidential issues for assignee' do expect(milestone.percent_complete(assignee)).to eq 57 end - it 'should count confidential issues for project members' do + it 'counts confidential issues for project members' do expect(milestone.percent_complete(member)).to eq 60 end - it 'should count confidential issues for admin' do + it 'counts confidential issues for admin' do expect(milestone.percent_complete(admin)).to eq 60 end end diff --git a/spec/models/concerns/project_features_compatibility_spec.rb b/spec/models/concerns/project_features_compatibility_spec.rb new file mode 100644 index 00000000000..5363aea4d22 --- /dev/null +++ b/spec/models/concerns/project_features_compatibility_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe ProjectFeaturesCompatibility do + let(:project) { create(:project) } + let(:features) { %w(issues wiki builds merge_requests snippets) } + + # We had issues_enabled, snippets_enabled, builds_enabled, merge_requests_enabled and issues_enabled fields on projects table + # All those fields got moved to a new table called project_feature and are now integers instead of booleans + # This spec tests if the described concern makes sure parameters received by the API are correctly parsed to the new table + # So we can keep it compatible + + it "converts fields from 'true' to ProjectFeature::ENABLED" do + features.each do |feature| + project.update_attribute("#{feature}_enabled".to_sym, "true") + expect(project.project_feature.public_send("#{feature}_access_level")).to eq(ProjectFeature::ENABLED) + end + end + + it "converts fields from 'false' to ProjectFeature::DISABLED" do + features.each do |feature| + project.update_attribute("#{feature}_enabled".to_sym, "false") + expect(project.project_feature.public_send("#{feature}_access_level")).to eq(ProjectFeature::DISABLED) + end + end +end diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb new file mode 100644 index 00000000000..32935bc0b09 --- /dev/null +++ b/spec/models/concerns/spammable_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Issue, 'Spammable' do + let(:issue) { create(:issue, description: 'Test Desc.') } + + describe 'Associations' do + it { is_expected.to have_one(:user_agent_detail).dependent(:destroy) } + end + + describe 'ClassMethods' do + it 'should return correct attr_spammable' do + expect(issue.spammable_text).to eq("#{issue.title}\n#{issue.description}") + end + end + + describe 'InstanceMethods' do + it 'should be invalid if spam' do + issue = build(:issue, spam: true) + expect(issue.valid?).to be_falsey + end + + describe '#check_for_spam?' do + it 'returns true for public project' do + issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + expect(issue.check_for_spam?).to eq(true) + end + + it 'returns false for other visibility levels' do + expect(issue.check_for_spam?).to eq(false) + end + end + end +end diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 9e8ebc56a31..eb64f3d0c83 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -41,7 +41,7 @@ describe ApplicationSetting, 'TokenAuthenticatable' do describe 'ensured! token' do subject { described_class.new.send("ensure_#{token_field}!") } - it 'should persist new token' do + it 'persists new token' do expect(subject).to eq described_class.current[token_field] end end diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb new file mode 100644 index 00000000000..7691d690db0 --- /dev/null +++ b/spec/models/cycle_analytics/code_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +describe 'CycleAnalytics#code', feature: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + context 'with deployment' do + generate_cycle_analytics_spec( + phase: :code, + data_fn: -> (context) { { issue: context.create(:issue, project: context.project) } }, + start_time_conditions: [["issue mentioned in a commit", + -> (context, data) do + context.create_commit_referencing_issue(data[:issue]) + end]], + end_time_conditions: [["merge request that closes issue is created", + -> (context, data) do + context.create_merge_request_closing_issue(data[:issue]) + end]], + post_fn: -> (context, data) do + context.merge_merge_requests_closing_issue(data[:issue]) + context.deploy_master + end) + + context "when a regular merge request (that doesn't close the issue) is created" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + + create_commit_referencing_issue(issue) + create_merge_request_closing_issue(issue, message: "Closes nothing") + + merge_merge_requests_closing_issue(issue) + deploy_master + end + + expect(subject.code).to be_nil + end + end + end + + context 'without deployment' do + generate_cycle_analytics_spec( + phase: :code, + data_fn: -> (context) { { issue: context.create(:issue, project: context.project) } }, + start_time_conditions: [["issue mentioned in a commit", + -> (context, data) do + context.create_commit_referencing_issue(data[:issue]) + end]], + end_time_conditions: [["merge request that closes issue is created", + -> (context, data) do + context.create_merge_request_closing_issue(data[:issue]) + end]], + post_fn: -> (context, data) do + context.merge_merge_requests_closing_issue(data[:issue]) + end) + + context "when a regular merge request (that doesn't close the issue) is created" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + + create_commit_referencing_issue(issue) + create_merge_request_closing_issue(issue, message: "Closes nothing") + + merge_merge_requests_closing_issue(issue) + end + + expect(subject.code).to be_nil + end + end + end +end diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb new file mode 100644 index 00000000000..f649b44d367 --- /dev/null +++ b/spec/models/cycle_analytics/issue_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe 'CycleAnalytics#issue', models: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + generate_cycle_analytics_spec( + phase: :issue, + data_fn: -> (context) { { issue: context.build(:issue, project: context.project) } }, + start_time_conditions: [["issue created", -> (context, data) { data[:issue].save }]], + end_time_conditions: [["issue associated with a milestone", + -> (context, data) do + if data[:issue].persisted? + data[:issue].update(milestone: context.create(:milestone, project: context.project)) + end + end], + ["list label added to issue", + -> (context, data) do + if data[:issue].persisted? + data[:issue].update(label_ids: [context.create(:label, lists: [context.create(:list)]).id]) + end + end]], + post_fn: -> (context, data) do + if data[:issue].persisted? + context.create_merge_request_closing_issue(data[:issue].reload) + context.merge_merge_requests_closing_issue(data[:issue]) + end + end) + + context "when a regular label (instead of a list label) is added to the issue" do + it "returns nil" do + 5.times do + regular_label = create(:label) + issue = create(:issue, project: project) + issue.update(label_ids: [regular_label.id]) + + create_merge_request_closing_issue(issue) + merge_merge_requests_closing_issue(issue) + end + + expect(subject.issue).to be_nil + end + end +end diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb new file mode 100644 index 00000000000..2cdefbeef21 --- /dev/null +++ b/spec/models/cycle_analytics/plan_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe 'CycleAnalytics#plan', feature: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + generate_cycle_analytics_spec( + phase: :plan, + data_fn: -> (context) do + { + issue: context.create(:issue, project: context.project), + branch_name: context.random_git_name + } + end, + start_time_conditions: [["issue associated with a milestone", + -> (context, data) do + data[:issue].update(milestone: context.create(:milestone, project: context.project)) + end], + ["list label added to issue", + -> (context, data) do + data[:issue].update(label_ids: [context.create(:label, lists: [context.create(:list)]).id]) + end]], + end_time_conditions: [["issue mentioned in a commit", + -> (context, data) do + context.create_commit_referencing_issue(data[:issue], branch_name: data[:branch_name]) + end]], + post_fn: -> (context, data) do + context.create_merge_request_closing_issue(data[:issue], source_branch: data[:branch_name]) + context.merge_merge_requests_closing_issue(data[:issue]) + end) + + context "when a regular label (instead of a list label) is added to the issue" do + it "returns nil" do + branch_name = random_git_name + label = create(:label) + issue = create(:issue, project: project) + issue.update(label_ids: [label.id]) + create_commit_referencing_issue(issue, branch_name: branch_name) + + create_merge_request_closing_issue(issue, source_branch: branch_name) + merge_merge_requests_closing_issue(issue) + + expect(subject.issue).to be_nil + end + end +end diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb new file mode 100644 index 00000000000..1f5e5cab92d --- /dev/null +++ b/spec/models/cycle_analytics/production_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe 'CycleAnalytics#production', feature: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + generate_cycle_analytics_spec( + phase: :production, + data_fn: -> (context) { { issue: context.build(:issue, project: context.project) } }, + start_time_conditions: [["issue is created", -> (context, data) { data[:issue].save }]], + before_end_fn: lambda do |context, data| + context.create_merge_request_closing_issue(data[:issue]) + context.merge_merge_requests_closing_issue(data[:issue]) + end, + end_time_conditions: + [["merge request that closes issue is deployed to production", -> (context, data) { context.deploy_master }], + ["production deploy happens after merge request is merged (along with other changes)", + lambda do |context, data| + # Make other changes on master + sha = context.project.repository.commit_file(context.user, context.random_git_name, "content", "commit message", 'master', false) + context.project.repository.commit(sha) + + context.deploy_master + end]]) + + context "when a regular merge request (that doesn't close the issue) is merged and deployed" do + it "returns nil" do + 5.times do + merge_request = create(:merge_request) + MergeRequests::MergeService.new(project, user).execute(merge_request) + deploy_master + end + + expect(subject.production).to be_nil + end + end + + context "when the deployment happens to a non-production environment" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + merge_request = create_merge_request_closing_issue(issue) + MergeRequests::MergeService.new(project, user).execute(merge_request) + deploy_master(environment: 'staging') + end + + expect(subject.production).to be_nil + end + end +end diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb new file mode 100644 index 00000000000..0ed080a42b1 --- /dev/null +++ b/spec/models/cycle_analytics/review_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe 'CycleAnalytics#review', feature: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + generate_cycle_analytics_spec( + phase: :review, + data_fn: -> (context) { { issue: context.create(:issue, project: context.project) } }, + start_time_conditions: [["merge request that closes issue is created", + -> (context, data) do + context.create_merge_request_closing_issue(data[:issue]) + end]], + end_time_conditions: [["merge request that closes issue is merged", + -> (context, data) do + context.merge_merge_requests_closing_issue(data[:issue]) + end]], + post_fn: nil) + + context "when a regular merge request (that doesn't close the issue) is created and merged" do + it "returns nil" do + 5.times do + MergeRequests::MergeService.new(project, user).execute(create(:merge_request)) + end + + expect(subject.review).to be_nil + end + end +end diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb new file mode 100644 index 00000000000..af1c4477ddb --- /dev/null +++ b/spec/models/cycle_analytics/staging_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe 'CycleAnalytics#staging', feature: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + generate_cycle_analytics_spec( + phase: :staging, + data_fn: lambda do |context| + issue = context.create(:issue, project: context.project) + { issue: issue, merge_request: context.create_merge_request_closing_issue(issue) } + end, + start_time_conditions: [["merge request that closes issue is merged", + -> (context, data) do + context.merge_merge_requests_closing_issue(data[:issue]) + end ]], + end_time_conditions: [["merge request that closes issue is deployed to production", + -> (context, data) do + context.deploy_master + end], + ["production deploy happens after merge request is merged (along with other changes)", + lambda do |context, data| + # Make other changes on master + sha = context.project.repository.commit_file( + context.user, + context.random_git_name, + "content", + "commit message", + 'master', + false) + context.project.repository.commit(sha) + + context.deploy_master + end]]) + + context "when a regular merge request (that doesn't close the issue) is merged and deployed" do + it "returns nil" do + 5.times do + merge_request = create(:merge_request) + MergeRequests::MergeService.new(project, user).execute(merge_request) + deploy_master + end + + expect(subject.staging).to be_nil + end + end + + context "when the deployment happens to a non-production environment" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + merge_request = create_merge_request_closing_issue(issue) + MergeRequests::MergeService.new(project, user).execute(merge_request) + deploy_master(environment: 'staging') + end + + expect(subject.staging).to be_nil + end + end +end diff --git a/spec/models/cycle_analytics/summary_spec.rb b/spec/models/cycle_analytics/summary_spec.rb new file mode 100644 index 00000000000..9d67bc82cba --- /dev/null +++ b/spec/models/cycle_analytics/summary_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe CycleAnalytics::Summary, models: true do + let(:project) { create(:project) } + let(:from) { Time.now } + let(:user) { create(:user, :admin) } + subject { described_class.new(project, from: from) } + + describe "#new_issues" do + it "finds the number of issues created after the 'from date'" do + Timecop.freeze(5.days.ago) { create(:issue, project: project) } + Timecop.freeze(5.days.from_now) { create(:issue, project: project) } + + expect(subject.new_issues).to eq(1) + end + + it "doesn't find issues from other projects" do + Timecop.freeze(5.days.from_now) { create(:issue, project: create(:project)) } + + expect(subject.new_issues).to eq(0) + end + end + + describe "#commits" do + it "finds the number of commits created after the 'from date'" do + Timecop.freeze(5.days.ago) { create_commit("Test message", project, user, 'master') } + Timecop.freeze(5.days.from_now) { create_commit("Test message", project, user, 'master') } + + expect(subject.commits).to eq(1) + end + + it "doesn't find commits from other projects" do + Timecop.freeze(5.days.from_now) { create_commit("Test message", create(:project), user, 'master') } + + expect(subject.commits).to eq(0) + end + + it "finds a large (> 100) snumber of commits if present" do + Timecop.freeze(5.days.from_now) { create_commit("Test message", project, user, 'master', count: 100) } + + expect(subject.commits).to eq(100) + end + end + + describe "#deploys" do + it "finds the number of deploys made created after the 'from date'" do + Timecop.freeze(5.days.ago) { create(:deployment, project: project) } + Timecop.freeze(5.days.from_now) { create(:deployment, project: project) } + + expect(subject.deploys).to eq(1) + end + + it "doesn't find commits from other projects" do + Timecop.freeze(5.days.from_now) { create(:deployment, project: create(:project)) } + + expect(subject.deploys).to eq(0) + end + end +end diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb new file mode 100644 index 00000000000..02ddfeed9c1 --- /dev/null +++ b/spec/models/cycle_analytics/test_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' + +describe 'CycleAnalytics#test', feature: true do + extend CycleAnalyticsHelpers::TestGeneration + + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + subject { CycleAnalytics.new(project, from: from_date) } + + generate_cycle_analytics_spec( + phase: :test, + data_fn: lambda do |context| + issue = context.create(:issue, project: context.project) + merge_request = context.create_merge_request_closing_issue(issue) + pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project) + { pipeline: pipeline, issue: issue } + end, + start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]], + end_time_conditions: [["pipeline is finished", -> (context, data) { data[:pipeline].succeed! }]], + post_fn: -> (context, data) do + context.merge_merge_requests_closing_issue(data[:issue]) + end) + + context "when the pipeline is for a regular merge request (that doesn't close an issue)" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + merge_request = create_merge_request_closing_issue(issue) + pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) + + pipeline.run! + pipeline.succeed! + + merge_merge_requests_closing_issue(issue) + end + + expect(subject.test).to be_nil + end + end + + context "when the pipeline is not for a merge request" do + it "returns nil" do + 5.times do + pipeline = create(:ci_pipeline, ref: "refs/heads/master", sha: project.repository.commit('master').sha) + + pipeline.run! + pipeline.succeed! + end + + expect(subject.test).to be_nil + end + end + + context "when the pipeline is dropped (failed)" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + merge_request = create_merge_request_closing_issue(issue) + pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) + + pipeline.run! + pipeline.drop! + + merge_merge_requests_closing_issue(issue) + end + + expect(subject.test).to be_nil + end + end + + context "when the pipeline is cancelled" do + it "returns nil" do + 5.times do + issue = create(:issue, project: project) + merge_request = create_merge_request_closing_issue(issue) + pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) + + pipeline.run! + pipeline.cancel! + + merge_merge_requests_closing_issue(issue) + end + + expect(subject.test).to be_nil + end + end +end diff --git a/spec/models/deploy_key_spec.rb b/spec/models/deploy_key_spec.rb index 6a90598a629..93623e8e99b 100644 --- a/spec/models/deploy_key_spec.rb +++ b/spec/models/deploy_key_spec.rb @@ -1,9 +1,6 @@ require 'spec_helper' describe DeployKey, models: true do - let(:project) { create(:project) } - let(:deploy_key) { create(:deploy_key, projects: [project]) } - describe "Associations" do it { is_expected.to have_many(:deploy_keys_projects) } it { is_expected.to have_many(:projects) } diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 7df3df4bb9e..01a4a53a264 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -15,4 +15,37 @@ describe Deployment, models: true do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } + + describe '#includes_commit?' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + let(:deployment) do + create(:deployment, environment: environment, sha: project.commit.id) + end + + context 'when there is no project commit' do + it 'returns false' do + commit = project.commit('feature') + + expect(deployment.includes_commit?(commit)).to be false + end + end + + context 'when they share the same tree branch' do + it 'returns true' do + commit = project.commit + + expect(deployment.includes_commit?(commit)).to be true + end + end + + context 'when the SHA for the deployment does not exist in the repo' do + it 'returns false' do + deployment.update(sha: Gitlab::Git::BLANK_SHA) + commit = project.commit + + expect(deployment.includes_commit?(commit)).to be false + end + end + end end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 1fa96eb1f15..3db5937a4f3 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -31,6 +31,43 @@ describe DiffNote, models: true do subject { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } + describe ".resolve!" do + let(:current_user) { create(:user) } + let!(:commit_note) { create(:diff_note_on_commit) } + let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) } + let!(:unresolved_note) { create(:diff_note_on_merge_request) } + + before do + described_class.resolve!(current_user) + + commit_note.reload + resolved_note.reload + unresolved_note.reload + end + + it 'resolves only the resolvable, not yet resolved notes' do + expect(commit_note.resolved_at).to be_nil + expect(resolved_note.resolved_by).not_to eq(current_user) + expect(unresolved_note.resolved_at).not_to be_nil + expect(unresolved_note.resolved_by).to eq(current_user) + end + end + + describe ".unresolve!" do + let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) } + + before do + described_class.unresolve! + + resolved_note.reload + end + + it 'unresolves the resolved notes' do + expect(resolved_note.resolved_by).to be_nil + expect(resolved_note.resolved_at).to be_nil + end + end + describe "#position=" do context "when provided a string" do it "sets the position" do @@ -103,7 +140,7 @@ describe DiffNote, models: true do describe "#active?" do context "when noteable is a commit" do - subject { create(:diff_note_on_commit, project: project, position: position) } + subject { build(:diff_note_on_commit, project: project, position: position) } it "returns true" do expect(subject.active?).to be true @@ -188,4 +225,300 @@ describe DiffNote, models: true do end end end + + describe "#resolvable?" do + context "when noteable is a commit" do + subject { create(:diff_note_on_commit, project: project, position: position) } + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when noteable is a merge request" do + context "when a system note" do + before do + subject.system = true + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when a regular note" do + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + before do + allow(subject).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when not resolved" do + before do + allow(subject).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when already resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved status" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when not yet resolved" do + it "returns true" do + expect(subject.resolve!(current_user)).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns true" do + expect(subject.unresolve!).to be true + end + + it "unsets resolved_at" do + subject.unresolve! + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when not resolved" do + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + end + end + + describe "#discussion" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.discussion).to be_nil + end + end + + context "when resolvable" do + let!(:diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: subject.position) } + let!(:diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } + + let(:active_position2) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: 16, + new_line: 22, + diff_refs: merge_request.diff_refs + ) + end + + it "returns the discussion this note is in" do + discussion = subject.discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([subject, diff_note2]) + end + end + end + + describe "#discussion_id" do + let(:note) { create(:diff_note_on_merge_request) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + end + + describe "#original_discussion_id" do + let(:note) { create(:diff_note_on_merge_request) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.original_discussion_id).not_to be_nil + expect(note.original_discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:original_discussion_id, nil) + end + + it "has a discussion id" do + # The original_discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.original_discussion_id).not_to be_nil + expect(reloaded_note.original_discussion_id).to match(/\A\h{40}\z/) + end + end + end end diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb new file mode 100644 index 00000000000..0142706d140 --- /dev/null +++ b/spec/models/discussion_spec.rb @@ -0,0 +1,593 @@ +require 'spec_helper' + +describe Discussion, model: true do + subject { described_class.new([first_note, second_note, third_note]) } + + let(:first_note) { create(:diff_note_on_merge_request) } + let(:second_note) { create(:diff_note_on_merge_request) } + let(:third_note) { create(:diff_note_on_merge_request) } + + describe "#resolvable?" do + context "when a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(true) + end + + context "when all notes are unresolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(false) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when some notes are unresolvable and some notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + + context "when all notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(true) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + + context "when not a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + end + + describe "#resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.resolved?).to be true + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#can_resolve?" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when not signed in" do + let(:current_user) { nil } + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when signed in" do + context "when the signed in user is the noteable author" do + before do + subject.noteable.author = current_user + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user can push to the project" do + before do + subject.project.team << [current_user, :master] + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user is a random user" do + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + let(:user) { create(:user) } + let(:second_note) { create(:diff_note_on_commit) } # unresolvable + + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + + first_note.reload + third_note.reload + end + + it "doesn't change resolved_at on the resolved notes" do + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at } + end + + it "doesn't change resolved_by on the resolved notes" do + expect(first_note.resolved_by).to eq(user) + expect(third_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved notes" do + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? } + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved state" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "doesn't change resolved_at on the resolved note" do + expect(first_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload.resolved_at } + end + + it "doesn't change resolved_by on the resolved note" do + expect(first_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload && first_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved note" do + expect(first_note.resolved?).to be true + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload && first_note.resolved? } + end + + it "sets resolved_at on the unresolved note" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved note" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved note as resolved" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + + context "when no resolvable notes are resolved" do + it "sets resolved_at on the unresolved notes" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved notes" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved_by).to eq(current_user) + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved notes as resolved" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + let(:user) { create(:user) } + + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + end + + it "unsets resolved_at on the resolved notes" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved_at).to be_nil + expect(third_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved notes" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved_by).to be_nil + expect(third_note.resolved_by).to be_nil + end + + it "unmarks the resolved notes as resolved" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved?).to be false + expect(third_note.resolved?).to be false + end + + it "unsets resolved_at" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "unsets resolved_at on the resolved note" do + subject.unresolve! + + expect(subject.first_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved note" do + subject.unresolve! + + expect(subject.first_note.resolved_by).to be_nil + end + + it "unmarks the resolved note as resolved" do + subject.unresolve! + + expect(subject.first_note.resolved?).to be false + end + end + end + end + + describe "#collapsed?" do + context "when a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(true) + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + before do + allow(subject).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.collapsed?).to be true + end + end + + context "when not resolved" do + before do + allow(subject).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.collapsed?).to be false + end + end + end + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + context "when active" do + before do + allow(subject).to receive(:active?).and_return(true) + end + + it "returns false" do + expect(subject.collapsed?).to be false + end + end + + context "when outdated" do + before do + allow(subject).to receive(:active?).and_return(false) + end + + it "returns true" do + expect(subject.collapsed?).to be true + end + end + end + end + + context "when not a diff discussion" do + before do + allow(subject).to receive(:diff_discussion?).and_return(false) + end + + it "returns false" do + expect(subject.collapsed?).to be false + end + end + end +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 8a84ac0a7c7..e172ee8e590 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -30,4 +30,70 @@ describe Environment, models: true do expect(env.external_url).to be_nil end end + + describe '#includes_commit?' do + context 'without a last deployment' do + it "returns false" do + expect(environment.includes_commit?('HEAD')).to be false + end + end + + context 'with a last deployment' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + + let!(:deployment) do + create(:deployment, environment: environment, sha: project.commit('master').id) + end + + context 'in the same branch' do + it 'returns true' do + expect(environment.includes_commit?(RepoHelpers.sample_commit)).to be true + end + end + + context 'not in the same branch' do + before do + deployment.update(sha: project.commit('feature').id) + end + + it 'returns false' do + expect(environment.includes_commit?(RepoHelpers.sample_commit)).to be false + end + end + end + end + + describe '#first_deployment_for' do + let(:project) { create(:project) } + let!(:environment) { create(:environment, project: project) } + let!(:deployment) { create(:deployment, environment: environment, ref: commit.parent.id) } + let!(:deployment1) { create(:deployment, environment: environment, ref: commit.id) } + let(:head_commit) { project.commit } + let(:commit) { project.commit.parent } + + it 'returns deployment id for the environment' do + expect(environment.first_deployment_for(commit)).to eq deployment1 + end + + it 'return nil when no deployment is found' do + expect(environment.first_deployment_for(head_commit)).to eq nil + end + end + + describe '#environment_type' do + subject { environment.environment_type } + + it 'sets a environment type if name has multiple segments' do + environment.update!(name: 'production/worker.gitlab.com') + + is_expected.to eq('production') + end + + it 'nullifies a type if it\'s a simple name' do + environment.update!(name: 'production') + + is_expected.to be_nil + end + end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index b5d0d79e14e..733b79079ed 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -16,34 +16,28 @@ describe Event, models: true do describe 'Callbacks' do describe 'after_create :reset_project_activity' do - let(:project) { create(:project) } + let(:project) { create(:empty_project) } - context "project's last activity was less than 5 minutes ago" do - it 'does not update project.last_activity_at if it has been touched less than 5 minutes ago' do - create_event(project, project.owner) - project.update_column(:last_activity_at, 5.minutes.ago) - project_last_activity_at = project.last_activity_at + it 'calls the reset_project_activity method' do + expect_any_instance_of(Event).to receive(:reset_project_activity) - create_event(project, project.owner) - - expect(project.last_activity_at).to eq(project_last_activity_at) - end + create_event(project, project.owner) end end end describe "Push event" do - before do - project = create(:project) - @user = project.owner - @event = create_event(project, @user) + let(:project) { create(:project) } + let(:user) { project.owner } + let(:event) { create_event(project, user) } + + it do + expect(event.push?).to be_truthy + expect(event.visible_to_user?).to be_truthy + expect(event.tag?).to be_falsey + expect(event.branch_name).to eq("master") + expect(event.author).to eq(user) end - - it { expect(@event.push?).to be_truthy } - it { expect(@event.visible_to_user?).to be_truthy } - it { expect(@event.tag?).to be_falsey } - it { expect(@event.branch_name).to eq("master") } - it { expect(@event.author).to eq(@user) } end describe '#note?' do @@ -65,8 +59,8 @@ describe Event, models: true do describe '#visible_to_user?' do let(:project) { create(:empty_project, :public) } let(:non_member) { create(:user) } - let(:member) { create(:user) } - let(:guest) { create(:user) } + let(:member) { create(:user) } + let(:guest) { create(:user) } let(:author) { create(:author) } let(:assignee) { create(:user) } let(:admin) { create(:admin) } @@ -85,23 +79,27 @@ describe Event, models: true do context 'for non confidential issues' do let(:target) { issue } - it { expect(event.visible_to_user?(non_member)).to eq true } - it { expect(event.visible_to_user?(author)).to eq true } - it { expect(event.visible_to_user?(assignee)).to eq true } - it { expect(event.visible_to_user?(member)).to eq true } - it { expect(event.visible_to_user?(guest)).to eq true } - it { expect(event.visible_to_user?(admin)).to eq true } + it do + expect(event.visible_to_user?(non_member)).to eq true + expect(event.visible_to_user?(author)).to eq true + expect(event.visible_to_user?(assignee)).to eq true + expect(event.visible_to_user?(member)).to eq true + expect(event.visible_to_user?(guest)).to eq true + expect(event.visible_to_user?(admin)).to eq true + end end context 'for confidential issues' do let(:target) { confidential_issue } - it { expect(event.visible_to_user?(non_member)).to eq false } - it { expect(event.visible_to_user?(author)).to eq true } - it { expect(event.visible_to_user?(assignee)).to eq true } - it { expect(event.visible_to_user?(member)).to eq true } - it { expect(event.visible_to_user?(guest)).to eq false } - it { expect(event.visible_to_user?(admin)).to eq true } + it do + expect(event.visible_to_user?(non_member)).to eq false + expect(event.visible_to_user?(author)).to eq true + expect(event.visible_to_user?(assignee)).to eq true + expect(event.visible_to_user?(member)).to eq true + expect(event.visible_to_user?(guest)).to eq false + expect(event.visible_to_user?(admin)).to eq true + end end end @@ -109,23 +107,27 @@ describe Event, models: true do context 'on non confidential issues' do let(:target) { note_on_issue } - it { expect(event.visible_to_user?(non_member)).to eq true } - it { expect(event.visible_to_user?(author)).to eq true } - it { expect(event.visible_to_user?(assignee)).to eq true } - it { expect(event.visible_to_user?(member)).to eq true } - it { expect(event.visible_to_user?(guest)).to eq true } - it { expect(event.visible_to_user?(admin)).to eq true } + it do + expect(event.visible_to_user?(non_member)).to eq true + expect(event.visible_to_user?(author)).to eq true + expect(event.visible_to_user?(assignee)).to eq true + expect(event.visible_to_user?(member)).to eq true + expect(event.visible_to_user?(guest)).to eq true + expect(event.visible_to_user?(admin)).to eq true + end end context 'on confidential issues' do let(:target) { note_on_confidential_issue } - it { expect(event.visible_to_user?(non_member)).to eq false } - it { expect(event.visible_to_user?(author)).to eq true } - it { expect(event.visible_to_user?(assignee)).to eq true } - it { expect(event.visible_to_user?(member)).to eq true } - it { expect(event.visible_to_user?(guest)).to eq false } - it { expect(event.visible_to_user?(admin)).to eq true } + it do + expect(event.visible_to_user?(non_member)).to eq false + expect(event.visible_to_user?(author)).to eq true + expect(event.visible_to_user?(assignee)).to eq true + expect(event.visible_to_user?(member)).to eq true + expect(event.visible_to_user?(guest)).to eq false + expect(event.visible_to_user?(admin)).to eq true + end end end @@ -135,12 +137,27 @@ describe Event, models: true do let(:note_on_merge_request) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project) } let(:target) { note_on_merge_request } - it { expect(event.visible_to_user?(non_member)).to eq true } - it { expect(event.visible_to_user?(author)).to eq true } - it { expect(event.visible_to_user?(assignee)).to eq true } - it { expect(event.visible_to_user?(member)).to eq true } - it { expect(event.visible_to_user?(guest)).to eq true } - it { expect(event.visible_to_user?(admin)).to eq true } + it do + expect(event.visible_to_user?(non_member)).to eq true + expect(event.visible_to_user?(author)).to eq true + expect(event.visible_to_user?(assignee)).to eq true + expect(event.visible_to_user?(member)).to eq true + expect(event.visible_to_user?(guest)).to eq true + expect(event.visible_to_user?(admin)).to eq true + end + + context 'private project' do + let(:project) { create(:project, :private) } + + it do + expect(event.visible_to_user?(non_member)).to eq false + expect(event.visible_to_user?(author)).to eq true + expect(event.visible_to_user?(assignee)).to eq true + expect(event.visible_to_user?(member)).to eq true + expect(event.visible_to_user?(guest)).to eq false + expect(event.visible_to_user?(admin)).to eq true + end + end end end @@ -161,6 +178,33 @@ describe Event, models: true do end end + describe '#reset_project_activity' do + let(:project) { create(:empty_project) } + + context 'when a project was updated less than 1 hour ago' do + it 'does not update the project' do + project.update(last_activity_at: Time.now) + + expect(project).not_to receive(:update_column). + with(:last_activity_at, a_kind_of(Time)) + + create_event(project, project.owner) + end + end + + context 'when a project was updated more than 1 hour ago' do + it 'updates the project' do + project.update(last_activity_at: 1.year.ago) + + create_event(project, project.owner) + + project.reload + + project.last_activity_at <= 1.minute.ago + end + end + end + def create_event(project, user, attrs = {}) data = { before: Gitlab::Git::BLANK_SHA, @@ -182,6 +226,6 @@ describe Event, models: true do action: Event::PUSHED, data: data, author_id: user.id - }.merge(attrs)) + }.merge!(attrs)) end end diff --git a/spec/models/forked_project_link_spec.rb b/spec/models/forked_project_link_spec.rb index f94987dcaff..1863581f57b 100644 --- a/spec/models/forked_project_link_spec.rb +++ b/spec/models/forked_project_link_spec.rb @@ -6,14 +6,15 @@ describe ForkedProjectLink, "add link on fork" do let(:user) { create(:user, namespace: namespace) } before do + create(:project_member, :reporter, user: user, project: project_from) @project_to = fork_project(project_from, user) end - it "project_to should know it is forked" do + it "project_to knows it is forked" do expect(@project_to.forked?).to be_truthy end - it "project should know who it is forked from" do + it "project knows who it is forked from" do expect(@project_to.forked_from_project).to eq(project_from) end end @@ -29,15 +30,15 @@ describe '#forked?' do forked_project_link.save! end - it "project_to should know it is forked" do + it "project_to knows it is forked" do expect(project_to.forked?).to be_truthy end - it "project_from should not be forked" do + it "project_from is not forked" do expect(project_from.forked?).to be_falsey end - it "project_to.destroy should destroy fork_link" do + it "project_to.destroy destroys fork_link" do expect(forked_project_link).to receive(:destroy) project_to.destroy end diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb index ae77ec5b348..dd033480527 100644 --- a/spec/models/global_milestone_spec.rb +++ b/spec/models/global_milestone_spec.rb @@ -29,15 +29,15 @@ describe GlobalMilestone, models: true do @global_milestones = GlobalMilestone.build_collection(milestones) end - it 'should have all project milestones' do + it 'has all project milestones' do expect(@global_milestones.count).to eq(2) end - it 'should have all project milestones titles' do + it 'has all project milestones titles' do expect(@global_milestones.map(&:title)).to match_array(['Milestone v1.2', 'VD-123']) end - it 'should have all project milestones' do + it 'has all project milestones' do expect(@global_milestones.map { |group_milestone| group_milestone.milestones.count }.sum).to eq(6) end end @@ -50,15 +50,16 @@ describe GlobalMilestone, models: true do milestone1_project2, milestone1_project3, ] + milestones_relation = Milestone.where(id: milestones.map(&:id)) - @global_milestone = GlobalMilestone.new(milestone1_project1.title, milestones) + @global_milestone = GlobalMilestone.new(milestone1_project1.title, milestones_relation) end - it 'should have exactly one group milestone' do + it 'has exactly one group milestone' do expect(@global_milestone.title).to eq('Milestone v1.2') end - it 'should have all project milestones with the same title' do + it 'has all project milestones with the same title' do expect(@global_milestone.milestones.count).to eq(3) end end @@ -66,8 +67,8 @@ describe GlobalMilestone, models: true do describe '#safe_title' do let(:milestone) { create(:milestone, title: "git / test", project: project1) } - it 'should strip out slashes and spaces' do - global_milestone = GlobalMilestone.new(milestone.title, [milestone]) + it 'strips out slashes and spaces' do + global_milestone = GlobalMilestone.new(milestone.title, Milestone.where(id: milestone.id)) expect(global_milestone.safe_title).to eq('git-test') end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 266c46213a6..0b3ef9b98fd 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -116,7 +116,7 @@ describe Group, models: true do let(:user) { create(:user) } before { group.add_users([user.id], GroupMember::GUEST) } - it "should update the group permission" do + it "updates the group permission" do expect(group.group_members.guests.map(&:user)).to include(user) group.add_users([user.id], GroupMember::DEVELOPER) expect(group.group_members.developers.map(&:user)).to include(user) @@ -128,12 +128,12 @@ describe Group, models: true do let(:user) { create(:user) } before { group.add_user(user, GroupMember::MASTER) } - it "should be true if avatar is image" do + it "is true if avatar is image" do group.update_attribute(:avatar, 'uploads/avatar.png') expect(group.avatar_type).to be_truthy end - it "should be false if avatar is html page" do + it "is false if avatar is html page" do group.update_attribute(:avatar, 'uploads/avatar.html') expect(group.avatar_type).to eq(["only images allowed"]) end @@ -187,6 +187,52 @@ describe Group, models: true do it { expect(group.has_master?(@members[:requester])).to be_falsey } end + describe '#lfs_enabled?' do + context 'LFS enabled globally' do + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + it 'returns true when nothing is set' do + expect(group.lfs_enabled?).to be_truthy + end + + it 'returns false when set to false' do + group.update_attribute(:lfs_enabled, false) + + expect(group.lfs_enabled?).to be_falsey + end + + it 'returns true when set to true' do + group.update_attribute(:lfs_enabled, true) + + expect(group.lfs_enabled?).to be_truthy + end + end + + context 'LFS disabled globally' do + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(false) + end + + it 'returns false when nothing is set' do + expect(group.lfs_enabled?).to be_falsey + end + + it 'returns false when set to false' do + group.update_attribute(:lfs_enabled, false) + + expect(group.lfs_enabled?).to be_falsey + end + + it 'returns false when set to true' do + group.update_attribute(:lfs_enabled, true) + + expect(group.lfs_enabled?).to be_falsey + end + end + end + describe '#owners' do let(:owner) { create(:user) } let(:developer) { create(:user) } diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb index 983848392b7..474ae62ccec 100644 --- a/spec/models/hooks/project_hook_spec.rb +++ b/spec/models/hooks/project_hook_spec.rb @@ -1,21 +1,3 @@ -# == Schema Information -# -# Table name: web_hooks -# -# id :integer not null, primary key -# url :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# type :string(255) default("ProjectHook") -# service_id :integer -# push_events :boolean default(TRUE), not null -# issues_events :boolean default(FALSE), not null -# merge_requests_events :boolean default(FALSE), not null -# tag_push_events :boolean default(FALSE) -# note_events :boolean default(FALSE), not null -# - require 'spec_helper' describe ProjectHook, models: true do @@ -24,7 +6,7 @@ describe ProjectHook, models: true do end describe '.push_hooks' do - it 'should return hooks for push events only' do + it 'returns hooks for push events only' do hook = create(:project_hook, push_events: true) create(:project_hook, push_events: false) expect(ProjectHook.push_hooks).to eq([hook]) @@ -32,7 +14,7 @@ describe ProjectHook, models: true do end describe '.tag_push_hooks' do - it 'should return hooks for tag push events only' do + it 'returns hooks for tag push events only' do hook = create(:project_hook, tag_push_events: true) create(:project_hook, tag_push_events: false) expect(ProjectHook.tag_push_hooks).to eq([hook]) diff --git a/spec/models/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb index 534e1b4f128..1a83c836652 100644 --- a/spec/models/hooks/service_hook_spec.rb +++ b/spec/models/hooks/service_hook_spec.rb @@ -1,21 +1,3 @@ -# == Schema Information -# -# Table name: web_hooks -# -# id :integer not null, primary key -# url :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# type :string(255) default("ProjectHook") -# service_id :integer -# push_events :boolean default(TRUE), not null -# issues_events :boolean default(FALSE), not null -# merge_requests_events :boolean default(FALSE), not null -# tag_push_events :boolean default(FALSE) -# note_events :boolean default(FALSE), not null -# - require "spec_helper" describe ServiceHook, models: true do diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb index 4078b9e4ff5..ad2b710041a 100644 --- a/spec/models/hooks/system_hook_spec.rb +++ b/spec/models/hooks/system_hook_spec.rb @@ -1,21 +1,3 @@ -# == Schema Information -# -# Table name: web_hooks -# -# id :integer not null, primary key -# url :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# type :string(255) default("ProjectHook") -# service_id :integer -# push_events :boolean default(TRUE), not null -# issues_events :boolean default(FALSE), not null -# merge_requests_events :boolean default(FALSE), not null -# tag_push_events :boolean default(FALSE) -# note_events :boolean default(FALSE), not null -# - require "spec_helper" describe SystemHook, models: true do @@ -38,7 +20,7 @@ describe SystemHook, models: true do end it "project_destroy hook" do - Projects::DestroyService.new(project, user, {}).pending_delete! + Projects::DestroyService.new(project, user, {}).async_execute expect(WebMock).to have_requested(:post, system_hook.url).with( body: /project_destroy/, @@ -48,7 +30,7 @@ describe SystemHook, models: true do it "user_create hook" do create(:user) - + expect(WebMock).to have_requested(:post, system_hook.url).with( body: /user_create/, headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'System Hook' } diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index f9bab487b96..e52b9d75cef 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -1,21 +1,3 @@ -# == Schema Information -# -# Table name: web_hooks -# -# id :integer not null, primary key -# url :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# type :string(255) default("ProjectHook") -# service_id :integer -# push_events :boolean default(TRUE), not null -# issues_events :boolean default(FALSE), not null -# merge_requests_events :boolean default(FALSE), not null -# tag_push_events :boolean default(FALSE) -# note_events :boolean default(FALSE), not null -# - require 'spec_helper' describe WebHook, models: true do diff --git a/spec/models/issue/metrics_spec.rb b/spec/models/issue/metrics_spec.rb new file mode 100644 index 00000000000..e170b087ebc --- /dev/null +++ b/spec/models/issue/metrics_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Issue::Metrics, models: true do + let(:project) { create(:project) } + + subject { create(:issue, project: project) } + + describe "when recording the default set of issue metrics on issue save" do + context "milestones" do + it "records the first time an issue is associated with a milestone" do + time = Time.now + Timecop.freeze(time) { subject.update(milestone: create(:milestone)) } + metrics = subject.metrics + + expect(metrics).to be_present + expect(metrics.first_associated_with_milestone_at).to be_within(1.second).of(time) + end + + it "does not record the second time an issue is associated with a milestone" do + time = Time.now + Timecop.freeze(time) { subject.update(milestone: create(:milestone)) } + Timecop.freeze(time + 2.hours) { subject.update(milestone: nil) } + Timecop.freeze(time + 6.hours) { subject.update(milestone: create(:milestone)) } + metrics = subject.metrics + + expect(metrics).to be_present + expect(metrics.first_associated_with_milestone_at).to be_within(1.second).of(time) + end + end + + context "list labels" do + it "records the first time an issue is associated with a list label" do + list_label = create(:label, lists: [create(:list)]) + time = Time.now + Timecop.freeze(time) { subject.update(label_ids: [list_label.id]) } + metrics = subject.metrics + + expect(metrics).to be_present + expect(metrics.first_added_to_board_at).to be_within(1.second).of(time) + end + + it "does not record the second time an issue is associated with a list label" do + time = Time.now + first_list_label = create(:label, lists: [create(:list)]) + Timecop.freeze(time) { subject.update(label_ids: [first_list_label.id]) } + second_list_label = create(:label, lists: [create(:list)]) + Timecop.freeze(time + 5.hours) { subject.update(label_ids: [second_list_label.id]) } + metrics = subject.metrics + + expect(metrics).to be_present + expect(metrics.first_added_to_board_at).to be_within(1.second).of(time) + end + end + end +end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 3259f795296..3b8b743af2d 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -494,7 +494,7 @@ describe Issue, models: true do context 'with an admin user' do let(:project) { create(:empty_project) } - let(:user) { create(:user, admin: true) } + let(:user) { create(:admin) } it 'returns true for a regular issue' do issue = build(:issue, project: project) diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 6d68e52a822..7fc6ed1dd54 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -5,9 +5,6 @@ describe Key, models: true do it { is_expected.to belong_to(:user) } end - describe "Mass assignment" do - end - describe "Validation" do it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:key) } @@ -73,13 +70,13 @@ describe Key, models: true do end context 'callbacks' do - it 'should add new key to authorized_file' do + it 'adds new key to authorized_file' do @key = build(:personal_key, id: 7) expect(GitlabShellWorker).to receive(:perform_async).with(:add_key, @key.shell_id, @key.key) @key.save end - it 'should remove key from authorized_file' do + it 'removes key from authorized_file' do @key = create(:personal_key) expect(GitlabShellWorker).to receive(:perform_async).with(:remove_key, @key.shell_id, @key.key) @key.destroy diff --git a/spec/models/label_link_spec.rb b/spec/models/label_link_spec.rb index 5e6f8ca1528..c18ed8574b1 100644 --- a/spec/models/label_link_spec.rb +++ b/spec/models/label_link_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' describe LabelLink, models: true do - let(:label) { create(:label_link) } - it { expect(label).to be_valid } + it { expect(build(:label_link)).to be_valid } it { is_expected.to belong_to(:label) } it { is_expected.to belong_to(:target) } diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index f37f44a608e..5a5d1a5d60c 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -5,8 +5,10 @@ describe Label, models: true do describe 'associations' do it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:label_links).dependent(:destroy) } it { is_expected.to have_many(:issues).through(:label_links).source(:target) } + it { is_expected.to have_many(:lists).dependent(:destroy) } end describe 'modules' do @@ -18,7 +20,7 @@ describe Label, models: true do describe 'validation' do it { is_expected.to validate_presence_of(:project) } - it 'should validate color code' do + it 'validates color code' do expect(label).not_to allow_value('G-ITLAB').for(:color) expect(label).not_to allow_value('AABBCC').for(:color) expect(label).not_to allow_value('#AABBCCEE').for(:color) @@ -30,7 +32,7 @@ describe Label, models: true do expect(label).to allow_value('#abcdef').for(:color) end - it 'should validate title' do + it 'validates title' do expect(label).not_to allow_value('G,ITLAB').for(:title) expect(label).not_to allow_value('').for(:title) diff --git a/spec/models/legacy_diff_note_spec.rb b/spec/models/legacy_diff_note_spec.rb index c8ee656fe3b..81517a18b74 100644 --- a/spec/models/legacy_diff_note_spec.rb +++ b/spec/models/legacy_diff_note_spec.rb @@ -5,12 +5,12 @@ describe LegacyDiffNote, models: true do let!(:note) { create(:legacy_diff_note_on_commit, note: "+1 from me") } let!(:commit) { note.noteable } - it "should save a valid note" do + it "saves a valid note" do expect(note.commit_id).to eq(commit.id) expect(note.noteable.id).to eq(commit.id) end - it "should be recognized by #legacy_diff_note?" do + it "is recognized by #legacy_diff_note?" do expect(note).to be_legacy_diff_note end end @@ -73,4 +73,29 @@ describe LegacyDiffNote, models: true do end end end + + describe "#discussion_id" do + let(:note) { create(:note) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + end end diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb new file mode 100644 index 00000000000..9e1a52011c3 --- /dev/null +++ b/spec/models/list_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +describe List do + describe 'relationships' do + it { is_expected.to belong_to(:board) } + it { is_expected.to belong_to(:label) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:board) } + it { is_expected.to validate_presence_of(:label) } + it { is_expected.to validate_presence_of(:list_type) } + it { is_expected.to validate_presence_of(:position) } + it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) } + + it 'validates uniqueness of label scoped to board_id' do + create(:list) + + expect(subject).to validate_uniqueness_of(:label_id).scoped_to(:board_id) + end + + context 'when list_type is set to backlog' do + subject { described_class.new(list_type: :backlog) } + + it { is_expected.not_to validate_presence_of(:label) } + it { is_expected.not_to validate_presence_of(:position) } + end + + context 'when list_type is set to done' do + subject { described_class.new(list_type: :done) } + + it { is_expected.not_to validate_presence_of(:label) } + it { is_expected.not_to validate_presence_of(:position) } + end + end + + describe '#destroy' do + it 'can be destroyed when when list_type is set to label' do + subject = create(:list) + + expect(subject.destroy).to be_truthy + end + + it 'can not be destroyed when list_type is set to backlog' do + subject = create(:backlog_list) + + expect(subject.destroy).to be_falsey + end + + it 'can not be destroyed when when list_type is set to done' do + subject = create(:done_list) + + expect(subject.destroy).to be_falsey + end + end + + describe '#destroyable?' do + it 'retruns true when list_type is set to label' do + subject.list_type = :label + + expect(subject).to be_destroyable + end + + it 'retruns false when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject).not_to be_destroyable + end + + it 'retruns false when list_type is set to done' do + subject.list_type = :done + + expect(subject).not_to be_destroyable + end + end + + describe '#movable?' do + it 'retruns true when list_type is set to label' do + subject.list_type = :label + + expect(subject).to be_movable + end + + it 'retruns false when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject).not_to be_movable + end + + it 'retruns false when list_type is set to done' do + subject.list_type = :done + + expect(subject).not_to be_movable + end + end + + describe '#title' do + it 'returns label name when list_type is set to label' do + subject.list_type = :label + subject.label = Label.new(name: 'Development') + + expect(subject.title).to eq 'Development' + end + + it 'returns Backlog when list_type is set to backlog' do + subject.list_type = :backlog + + expect(subject.title).to eq 'Backlog' + end + + it 'returns Done when list_type is set to done' do + subject.list_type = :done + + expect(subject.title).to eq 'Done' + end + end +end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 44cd3c08718..485121701af 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -10,7 +10,7 @@ describe Member, models: true do it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:source) } - it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } + it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.all_values) } it_behaves_like 'an object with email-formated attributes', :invite_email do subject { build(:project_member) } @@ -57,7 +57,7 @@ describe Member, models: true do describe 'Scopes & finders' do before do - project = create(:project) + project = create(:empty_project, :public) group = create(:group) @owner_user = create(:user).tap { |u| group.add_owner(u) } @owner = group.members.find_by(user_id: @owner_user.id) @@ -65,12 +65,26 @@ describe Member, models: true do @master_user = create(:user).tap { |u| project.team << [u, :master] } @master = project.members.find_by(user_id: @master_user.id) - ProjectMember.add_user(project.members, 'toto1@example.com', Gitlab::Access::DEVELOPER, @master_user) - @invited_member = project.members.invite.find_by_invite_email('toto1@example.com') + @blocked_user = create(:user).tap do |u| + project.team << [u, :master] + project.team << [u, :developer] - accepted_invite_user = build(:user) - ProjectMember.add_user(project.members, 'toto2@example.com', Gitlab::Access::DEVELOPER, @master_user) - @accepted_invite_member = project.members.invite.find_by_invite_email('toto2@example.com').tap { |u| u.accept_invite!(accepted_invite_user) } + u.block! + end + @blocked_master = project.members.find_by(user_id: @blocked_user.id, access_level: Gitlab::Access::MASTER) + @blocked_developer = project.members.find_by(user_id: @blocked_user.id, access_level: Gitlab::Access::DEVELOPER) + + @invited_member = create(:project_member, :developer, + project: project, + invite_token: '1234', + invite_email: 'toto1@example.com') + + accepted_invite_user = build(:user, state: :active) + @accepted_invite_member = create(:project_member, :developer, + project: project, + invite_token: '1234', + invite_email: 'toto2@example.com'). + tap { |u| u.accept_invite!(accepted_invite_user) } requested_user = create(:user).tap { |u| project.request_access(u) } @requested_member = project.requesters.find_by(user_id: requested_user.id) @@ -81,7 +95,7 @@ describe Member, models: true do describe '.access_for_user_ids' do it 'returns the right access levels' do - users = [@owner_user.id, @master_user.id] + users = [@owner_user.id, @master_user.id, @blocked_user.id] expected = { @owner_user.id => Gitlab::Access::OWNER, @master_user.id => Gitlab::Access::MASTER @@ -115,6 +129,19 @@ describe Member, models: true do it { expect(described_class.request).not_to include @accepted_request_member } end + describe '.developers' do + subject { described_class.developers.to_a } + + it { is_expected.not_to include @owner } + it { is_expected.not_to include @master } + it { is_expected.to include @invited_member } + it { is_expected.to include @accepted_invite_member } + it { is_expected.not_to include @requested_member } + it { is_expected.to include @accepted_request_member } + it { is_expected.not_to include @blocked_master } + it { is_expected.not_to include @blocked_developer } + end + describe '.owners_and_masters' do it { expect(described_class.owners_and_masters).to include @owner } it { expect(described_class.owners_and_masters).to include @master } @@ -122,6 +149,20 @@ describe Member, models: true do it { expect(described_class.owners_and_masters).not_to include @accepted_invite_member } it { expect(described_class.owners_and_masters).not_to include @requested_member } it { expect(described_class.owners_and_masters).not_to include @accepted_request_member } + it { expect(described_class.owners_and_masters).not_to include @blocked_master } + end + + describe '.has_access' do + subject { described_class.has_access.to_a } + + it { is_expected.to include @owner } + it { is_expected.to include @master } + it { is_expected.to include @invited_member } + it { is_expected.to include @accepted_invite_member } + it { is_expected.not_to include @requested_member } + it { is_expected.to include @accepted_request_member } + it { is_expected.not_to include @blocked_master } + it { is_expected.not_to include @blocked_developer } end end @@ -130,39 +171,209 @@ describe Member, models: true do it { is_expected.to respond_to(:user_email) } end - describe ".add_user" do - let!(:user) { create(:user) } - let(:project) { create(:project) } + describe '.add_user' do + %w[project group].each do |source_type| + context "when source is a #{source_type}" do + let!(:source) { create(source_type, :public) } + let!(:user) { create(:user) } + let!(:admin) { create(:admin) } - context "when called with a user id" do - it "adds the user as a member" do - Member.add_user(project.project_members, user.id, ProjectMember::MASTER) + it 'returns a <Source>Member object' do + member = described_class.add_user(source, user, :master) - expect(project.users).to include(user) - end - end + expect(member).to be_a "#{source_type.classify}Member".constantize + expect(member).to be_persisted + end - context "when called with a user object" do - it "adds the user as a member" do - Member.add_user(project.project_members, user, ProjectMember::MASTER) + it 'sets members.created_by to the given current_user' do + member = described_class.add_user(source, user, :master, current_user: admin) - expect(project.users).to include(user) - end - end + expect(member.created_by).to eq(admin) + end - context "when called with a known user email" do - it "adds the user as a member" do - Member.add_user(project.project_members, user.email, ProjectMember::MASTER) + it 'sets members.expires_at to the given expires_at' do + member = described_class.add_user(source, user, :master, expires_at: Date.new(2016, 9, 22)) - expect(project.users).to include(user) - end - end + expect(member.expires_at).to eq(Date.new(2016, 9, 22)) + end + + described_class.access_levels.each do |sym_key, int_access_level| + it "accepts the :#{sym_key} symbol as access level" do + expect(source.users).not_to include(user) + + member = described_class.add_user(source, user.id, sym_key) + + expect(member.access_level).to eq(int_access_level) + expect(source.users.reload).to include(user) + end + + it "accepts the #{int_access_level} integer as access level" do + expect(source.users).not_to include(user) + + member = described_class.add_user(source, user.id, int_access_level) + + expect(member.access_level).to eq(int_access_level) + expect(source.users.reload).to include(user) + end + end + + context 'with no current_user' do + context 'when called with a known user id' do + it 'adds the user as a member' do + expect(source.users).not_to include(user) + + described_class.add_user(source, user.id, :master) + + expect(source.users.reload).to include(user) + end + end + + context 'when called with an unknown user id' do + it 'adds the user as a member' do + expect(source.users).not_to include(user) + + described_class.add_user(source, 42, :master) + + expect(source.users.reload).not_to include(user) + end + end + + context 'when called with a user object' do + it 'adds the user as a member' do + expect(source.users).not_to include(user) + + described_class.add_user(source, user, :master) + + expect(source.users.reload).to include(user) + end + end + + context 'when called with a requester user object' do + before do + source.request_access(user) + end + + it 'adds the requester as a member' do + expect(source.users).not_to include(user) + expect(source.requesters.exists?(user_id: user)).to be_truthy + + expect { described_class.add_user(source, user, :master) }. + to raise_error(Gitlab::Access::AccessDeniedError) + + expect(source.users.reload).not_to include(user) + expect(source.requesters.reload.exists?(user_id: user)).to be_truthy + end + end + + context 'when called with a known user email' do + it 'adds the user as a member' do + expect(source.users).not_to include(user) + + described_class.add_user(source, user.email, :master) + + expect(source.users.reload).to include(user) + end + end + + context 'when called with an unknown user email' do + it 'creates an invited member' do + expect(source.users).not_to include(user) + + described_class.add_user(source, 'user@example.com', :master) + + expect(source.members.invite.pluck(:invite_email)).to include('user@example.com') + end + end + end + + context 'when current_user can update member' do + it 'creates the member' do + expect(source.users).not_to include(user) + + described_class.add_user(source, user, :master, current_user: admin) + + expect(source.users.reload).to include(user) + end + + context 'when called with a requester user object' do + before do + source.request_access(user) + end + + it 'adds the requester as a member' do + expect(source.users).not_to include(user) + expect(source.requesters.exists?(user_id: user)).to be_truthy + + described_class.add_user(source, user, :master, current_user: admin) + + expect(source.users.reload).to include(user) + expect(source.requesters.reload.exists?(user_id: user)).to be_falsy + end + end + end + + context 'when current_user cannot update member' do + it 'does not create the member' do + expect(source.users).not_to include(user) + + member = described_class.add_user(source, user, :master, current_user: user) + + expect(source.users.reload).not_to include(user) + expect(member).not_to be_persisted + end + + context 'when called with a requester user object' do + before do + source.request_access(user) + end + + it 'does not destroy the requester' do + expect(source.users).not_to include(user) + expect(source.requesters.exists?(user_id: user)).to be_truthy + + described_class.add_user(source, user, :master, current_user: user) + + expect(source.users.reload).not_to include(user) + expect(source.requesters.exists?(user_id: user)).to be_truthy + end + end + end + + context 'when member already exists' do + before do + source.add_user(user, :developer) + end + + context 'with no current_user' do + it 'updates the member' do + expect(source.users).to include(user) + + described_class.add_user(source, user, :master) + + expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MASTER) + end + end + + context 'when current_user can update member' do + it 'updates the member' do + expect(source.users).to include(user) + + described_class.add_user(source, user, :master, current_user: admin) + + expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MASTER) + end + end + + context 'when current_user cannot update member' do + it 'does not update the member' do + expect(source.users).to include(user) - context "when called with an unknown user email" do - it "adds a member invite" do - Member.add_user(project.project_members, "user@example.com", ProjectMember::MASTER) + described_class.add_user(source, user, :master, current_user: user) - expect(project.project_members.invite.pluck(:invite_email)).to include("user@example.com") + expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::DEVELOPER) + end + end + end end end end diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 18439cac2a4..370aeb9e0a9 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -1,28 +1,36 @@ -# == Schema Information -# -# Table name: members -# -# id :integer not null, primary key -# access_level :integer not null -# source_id :integer not null -# source_type :string(255) not null -# user_id :integer -# notification_level :integer not null -# type :string(255) -# created_at :datetime -# updated_at :datetime -# created_by_id :integer -# invite_email :string(255) -# invite_token :string(255) -# invite_accepted_at :datetime -# - require 'spec_helper' describe GroupMember, models: true do + describe '.access_level_roles' do + it 'returns Gitlab::Access.options_with_owner' do + expect(described_class.access_level_roles).to eq(Gitlab::Access.options_with_owner) + end + end + + describe '.access_levels' do + it 'returns Gitlab::Access.options_with_owner' do + expect(described_class.access_levels).to eq(Gitlab::Access.sym_options_with_owner) + end + end + + describe '.add_users_to_group' do + it 'adds the given users to the given group' do + group = create(:group) + users = create_list(:user, 2) + + described_class.add_users_to_group( + group, + [users.first.id, users.second], + described_class::MASTER + ) + + expect(group.users).to include(users.first, users.second) + end + end + describe 'notifications' do describe "#after_create" do - it "should send email to user" do + it "sends email to user" do membership = build(:group_member) allow(membership).to receive(:notification_service). @@ -40,7 +48,7 @@ describe GroupMember, models: true do and_return(double('NotificationService').as_null_object) end - it "should send email to user" do + it "sends email to user" do expect(@group_member).to receive(:notification_service) @group_member.update_attribute(:access_level, GroupMember::MASTER) end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index ba622dfb9be..d85a1c1e3b2 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -1,22 +1,3 @@ -# == Schema Information -# -# Table name: members -# -# id :integer not null, primary key -# access_level :integer not null -# source_id :integer not null -# source_type :string(255) not null -# user_id :integer -# notification_level :integer not null -# type :string(255) -# created_at :datetime -# updated_at :datetime -# created_by_id :integer -# invite_email :string(255) -# invite_token :string(255) -# invite_accepted_at :datetime -# - require 'spec_helper' describe ProjectMember, models: true do @@ -27,12 +8,33 @@ describe ProjectMember, models: true do describe 'validations' do it { is_expected.to allow_value('Project').for(:source_type) } it { is_expected.not_to allow_value('project').for(:source_type) } + it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } end describe 'modules' do it { is_expected.to include_module(Gitlab::ShellAdapter) } end + describe '.access_level_roles' do + it 'returns Gitlab::Access.options' do + expect(described_class.access_level_roles).to eq(Gitlab::Access.options) + end + end + + describe '.add_user' do + context 'when called with the project owner' do + it 'adds the user as a member' do + project = create(:empty_project) + + expect(project.users).not_to include(project.owner) + + described_class.add_user(project, project.owner, :master, current_user: project.owner) + + expect(project.users.reload).to include(project.owner) + end + end + end + describe '#real_source_type' do subject { create(:project_member).real_source_type } @@ -40,7 +42,7 @@ describe ProjectMember, models: true do end describe "#destroy" do - let(:owner) { create(:project_member, access_level: ProjectMember::OWNER) } + let(:owner) { create(:project_member, access_level: ProjectMember::MASTER) } let(:project) { owner.project } let(:master) { create(:project_member, project: project) } @@ -52,7 +54,7 @@ describe ProjectMember, models: true do master_todos end - it "destroy itself and delete associated todos" do + it "destroys itself and delete associated todos" do expect(owner.user.todos.size).to eq(2) expect(master.user.todos.size).to eq(3) expect(Todo.count).to eq(5) @@ -68,11 +70,8 @@ describe ProjectMember, models: true do end end - describe :import_team do + describe '.import_team' do before do - @abilities = Six.new - @abilities << Ability - @project_1 = create :project @project_2 = create :project @@ -91,8 +90,8 @@ describe ProjectMember, models: true do it { expect(@project_2.users).to include(@user_1) } it { expect(@project_2.users).to include(@user_2) } - it { expect(@abilities.allowed?(@user_1, :create_project, @project_2)).to be_truthy } - it { expect(@abilities.allowed?(@user_2, :read_project, @project_2)).to be_truthy } + it { expect(Ability.allowed?(@user_1, :create_project, @project_2)).to be_truthy } + it { expect(Ability.allowed?(@user_2, :read_project, @project_2)).to be_truthy } end describe 'project 1 should not be changed' do @@ -101,26 +100,22 @@ describe ProjectMember, models: true do end end - describe '.add_users_into_projects' do - before do - @project_1 = create :project - @project_2 = create :project - - @user_1 = create :user - @user_2 = create :user + describe '.add_users_to_projects' do + it 'adds the given users to the given projects' do + projects = create_list(:empty_project, 2) + users = create_list(:user, 2) - ProjectMember.add_users_into_projects( - [@project_1.id, @project_2.id], - [@user_1.id, @user_2.id], - ProjectMember::MASTER - ) - end + described_class.add_users_to_projects( + [projects.first.id, projects.second], + [users.first.id, users.second], + described_class::MASTER) - it { expect(@project_1.users).to include(@user_1) } - it { expect(@project_1.users).to include(@user_2) } + expect(projects.first.users).to include(users.first) + expect(projects.first.users).to include(users.second) - it { expect(@project_2.users).to include(@user_1) } - it { expect(@project_2.users).to include(@user_2) } + expect(projects.second.users).to include(users.first) + expect(projects.second.users).to include(users.second) + end end describe '.truncate_teams' do diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb new file mode 100644 index 00000000000..a79dd215d41 --- /dev/null +++ b/spec/models/merge_request/metrics_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe MergeRequest::Metrics, models: true do + let(:project) { create(:project) } + + subject { create(:merge_request, source_project: project) } + + describe "when recording the default set of metrics on merge request save" do + it "records the merge time" do + time = Time.now + Timecop.freeze(time) { subject.mark_as_merged } + metrics = subject.metrics + + expect(metrics).to be_present + expect(metrics.merged_at).to be_within(1.second).of(time) + end + end +end diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 29f7396f862..e5007424041 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -1,6 +1,27 @@ require 'spec_helper' describe MergeRequestDiff, models: true do + describe 'create new record' do + subject { create(:merge_request).merge_request_diff } + + it { expect(subject).to be_valid } + it { expect(subject).to be_persisted } + it { expect(subject.commits.count).to eq(29) } + it { expect(subject.diffs.count).to eq(20) } + it { expect(subject.head_commit_sha).to eq('b83d6e391c22777fca1ed3012fce84f633d7fed0') } + it { expect(subject.base_commit_sha).to eq('ae73cb07c9eeaf35924a10f713b364d32b2dd34f') } + it { expect(subject.start_commit_sha).to eq('0b4bc9a49b562e85de7cc9e834518ea6828729b9') } + end + + describe '#latest' do + let!(:mr) { create(:merge_request, :with_diffs) } + let!(:first_diff) { mr.merge_request_diff } + let!(:last_diff) { mr.create_merge_request_diff } + + it { expect(last_diff.latest?).to be_truthy } + it { expect(first_diff.latest?).to be_falsey } + end + describe '#diffs' do let(:mr) { create(:merge_request, :with_diffs) } let(:mr_diff) { mr.merge_request_diff } @@ -23,6 +44,16 @@ describe MergeRequestDiff, models: true do end end + context 'when the raw diffs have invalid content' do + before { mr_diff.update_attributes(st_diffs: ["--broken-diff"]) } + + it 'returns an empty DiffCollection' do + expect(mr_diff.raw_diffs.to_a).to be_empty + expect(mr_diff.raw_diffs).to be_a(Gitlab::Git::DiffCollection) + expect(mr_diff.raw_diffs).to be_empty + end + end + context 'when the raw diffs exist' do it 'returns the diffs' do expect(mr_diff.raw_diffs).to be_a(Gitlab::Git::DiffCollection) @@ -44,4 +75,42 @@ describe MergeRequestDiff, models: true do end end end + + describe '#commits_sha' do + shared_examples 'returning all commits SHA' do + it 'returns all commits SHA' do + commits_sha = subject.commits_sha + + expect(commits_sha).to eq(subject.commits.map(&:sha)) + end + end + + context 'when commits were loaded' do + before do + subject.commits + end + + it_behaves_like 'returning all commits SHA' + end + + context 'when commits were not loaded' do + it_behaves_like 'returning all commits SHA' + end + end + + describe '#compare_with' do + subject { create(:merge_request, source_branch: 'fix').merge_request_diff } + + it 'delegates compare to the service' do + expect(CompareService).to receive(:new).and_call_original + + subject.compare_with(nil) + end + + it 'uses git diff A..B approach by default' do + diffs = subject.compare_with('0b4bc9a49b562e85de7cc9e834518ea6828729b9').diffs + + expect(diffs.size).to eq(3) + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 01c96ff1ab3..0dd37e353ab 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -9,7 +9,7 @@ describe MergeRequest, models: true do it { is_expected.to belong_to(:target_project).with_foreign_key(:target_project_id).class_name('Project') } it { is_expected.to belong_to(:source_project).with_foreign_key(:source_project_id).class_name('Project') } it { is_expected.to belong_to(:merge_user).class_name("User") } - it { is_expected.to have_one(:merge_request_diff).dependent(:destroy) } + it { is_expected.to have_many(:merge_request_diffs).dependent(:destroy) } end describe 'modules' do @@ -86,6 +86,30 @@ describe MergeRequest, models: true do end end + describe '#cache_merge_request_closes_issues!' do + before do + subject.project.team << [subject.author, :developer] + subject.target_branch = subject.project.default_branch + end + + it 'caches closed issues' do + issue = create :issue, project: subject.project + commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") + allow(subject).to receive(:commits).and_return([commit]) + + expect { subject.cache_merge_request_closes_issues! }.to change(subject.merge_requests_closing_issues, :count).by(1) + end + + it 'does not cache issues from external trackers' do + subject.project.update_attribute(:has_external_issue_tracker, true) + issue = ExternalIssue.new('JIRA-123', subject.project) + commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") + allow(subject).to receive(:commits).and_return([commit]) + + expect { subject.cache_merge_request_closes_issues! }.not_to change(subject.merge_requests_closing_issues, :count) + end + end + describe '#source_branch_sha' do let(:last_branch_commit) { subject.source_project.repository.commit(subject.source_branch) } @@ -159,7 +183,7 @@ describe MergeRequest, models: true do context 'when there are MR diffs' do it 'delegates to the MR diffs' do - merge_request.merge_request_diff = MergeRequestDiff.new + merge_request.save expect(merge_request.merge_request_diff).to receive(:raw_diffs).with(hash_including(options)) @@ -188,12 +212,12 @@ describe MergeRequest, models: true do create(:note, noteable: merge_request, project: merge_request.project) end - it "should include notes for commits" do + it "includes notes for commits" do expect(merge_request.commits).not_to be_empty expect(merge_request.mr_and_commit_notes.count).to eq(2) end - it "should include notes for commits from target project as well" do + it "includes notes for commits from target project as well" do create(:note_on_commit, commit_id: merge_request.commits.first.id, project: merge_request.target_project) @@ -287,6 +311,46 @@ describe MergeRequest, models: true do end end + describe "#wipless_title" do + ['WIP ', 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', ' [WIP] WIP [WIP] WIP: WIP '].each do |wip_prefix| + it "removes the '#{wip_prefix}' prefix" do + wipless_title = subject.title + subject.title = "#{wip_prefix}#{subject.title}" + + expect(subject.wipless_title).to eq wipless_title + end + + it "is satisfies the #work_in_progress? method" do + subject.title = "#{wip_prefix}#{subject.title}" + subject.title = subject.wipless_title + + expect(subject.work_in_progress?).to eq false + end + end + end + + describe "#wip_title" do + it "adds the WIP: prefix to the title" do + wip_title = "WIP: #{subject.title}" + + expect(subject.wip_title).to eq wip_title + end + + it "does not add the WIP: prefix multiple times" do + wip_title = "WIP: #{subject.title}" + subject.title = subject.wip_title + subject.title = subject.wip_title + + expect(subject.wip_title).to eq wip_title + end + + it "is satisfies the #work_in_progress? method" do + subject.title = subject.wip_title + + expect(subject.work_in_progress?).to eq true + end + end + describe '#can_remove_source_branch?' do let(:user) { create(:user) } let(:user2) { create(:user) } @@ -304,7 +368,7 @@ describe MergeRequest, models: true do expect(subject.can_remove_source_branch?(user)).to be_falsey end - it "cant remove a root ref" do + it "can't remove a root ref" do subject.source_branch = "master" subject.target_branch = "feature" @@ -329,6 +393,42 @@ describe MergeRequest, models: true do end end + describe '#merge_commit_message' do + it 'includes merge information as the title' do + request = build(:merge_request, source_branch: 'source', target_branch: 'target') + + expect(request.merge_commit_message) + .to match("Merge branch 'source' into 'target'\n\n") + end + + it 'includes its title in the body' do + request = build(:merge_request, title: 'Remove all technical debt') + + expect(request.merge_commit_message) + .to match("Remove all technical debt\n\n") + end + + it 'includes its description in the body' do + request = build(:merge_request, description: 'By removing all code') + + expect(request.merge_commit_message) + .to match("By removing all code\n\n") + end + + it 'includes its reference in the body' do + request = build_stubbed(:merge_request) + + expect(request.merge_commit_message) + .to match("See merge request #{request.to_reference}") + end + + it 'excludes multiple linebreak runs when description is blank' do + request = build(:merge_request, title: 'Title', description: nil) + + expect(request.merge_commit_message).not_to match("Title\n\n\n\n") + end + end + describe "#reset_merge_when_build_succeeds" do let(:merge_if_green) do create :merge_request, merge_when_build_succeeds: true, merge_user: create(:user), @@ -389,7 +489,7 @@ describe MergeRequest, models: true do subject(:merge_request_with_divergence) { create(:merge_request, :diverged, source_project: project, target_project: project) } it 'counts commits that are on target branch but not on source branch' do - expect(subject.diverged_commits_count).to eq(5) + expect(subject.diverged_commits_count).to eq(29) end end @@ -397,7 +497,7 @@ describe MergeRequest, models: true do subject(:merge_request_fork_with_divergence) { create(:merge_request, :diverged, source_project: fork_project, target_project: project) } it 'counts commits that are on target branch but not on source branch' do - expect(subject.diverged_commits_count).to eq(5) + expect(subject.diverged_commits_count).to eq(29) end end @@ -446,7 +546,7 @@ describe MergeRequest, models: true do end it_behaves_like 'an editable mentionable' do - subject { create(:merge_request) } + subject { create(:merge_request, :simple) } let(:backref_text) { "merge request #{subject.to_reference}" } let(:set_mentionable_text) { ->(txt){ subject.description = txt } } @@ -456,6 +556,20 @@ describe MergeRequest, models: true do subject { create :merge_request, :simple } end + describe '#commits_sha' do + let(:commit0) { double('commit0', sha: 'sha1') } + let(:commit1) { double('commit1', sha: 'sha2') } + let(:commit2) { double('commit2', sha: 'sha3') } + + before do + allow(subject.merge_request_diff).to receive(:commits).and_return([commit0, commit1, commit2]) + end + + it 'returns sha of commits' do + expect(subject.commits_sha).to contain_exactly('sha1', 'sha2', 'sha3') + end + end + describe '#pipeline' do describe 'when the source project exists' do it 'returns the latest pipeline' do @@ -463,8 +577,8 @@ describe MergeRequest, models: true do allow(subject).to receive(:diff_head_sha).and_return('123abc') - expect(subject.source_project).to receive(:pipeline). - with('123abc', 'master'). + expect(subject.source_project).to receive(:pipeline_for). + with('master', '123abc'). and_return(pipeline) expect(subject.pipeline).to eq(pipeline) @@ -480,6 +594,81 @@ describe MergeRequest, models: true do end end + describe '#all_pipelines' do + shared_examples 'returning pipelines with proper ordering' do + let!(:all_pipelines) do + subject.all_commits_sha.map do |sha| + create(:ci_empty_pipeline, + project: subject.source_project, + sha: sha, + ref: subject.source_branch) + end + end + + it 'returns all pipelines' do + expect(subject.all_pipelines).not_to be_empty + expect(subject.all_pipelines).to eq(all_pipelines.reverse) + end + end + + context 'with single merge_request_diffs' do + it_behaves_like 'returning pipelines with proper ordering' + end + + context 'with multiple irrelevant merge_request_diffs' do + before do + subject.update(target_branch: 'v1.0.0') + end + + it_behaves_like 'returning pipelines with proper ordering' + end + + context 'with unsaved merge request' do + subject { build(:merge_request) } + + let!(:pipeline) do + create(:ci_empty_pipeline, + project: subject.project, + sha: subject.diff_head_sha, + ref: subject.source_branch) + end + + it 'returns pipelines from diff_head_sha' do + expect(subject.all_pipelines).to contain_exactly(pipeline) + end + end + end + + describe '#all_commits_sha' do + let(:all_commits_sha) do + subject.merge_request_diffs.flat_map(&:commits).map(&:sha).uniq + end + + shared_examples 'returning all SHA' do + it 'returns all SHA from all merge_request_diffs' do + expect(subject.merge_request_diffs.size).to eq(2) + expect(subject.all_commits_sha).to eq(all_commits_sha) + end + end + + context 'with a completely different branch' do + before do + subject.update(target_branch: 'v1.0.0') + end + + it_behaves_like 'returning all SHA' + end + + context 'with a branch having no difference' do + before do + subject.update(target_branch: 'v1.1.0') + subject.reload # make sure commits were not cached + end + + it_behaves_like 'returning all SHA' + end + end + describe '#participants' do let(:project) { create(:project, :public) } @@ -674,17 +863,84 @@ describe MergeRequest, models: true do end end + describe '#environments' do + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + + context 'with multiple environments' do + let(:environments) { create_list(:environment, 3, project: project) } + + before do + create(:deployment, environment: environments.first, ref: 'master', sha: project.commit('master').id) + create(:deployment, environment: environments.second, ref: 'feature', sha: project.commit('feature').id) + end + + it 'selects deployed environments' do + expect(merge_request.environments).to contain_exactly(environments.first) + end + end + + context 'with environments on source project' do + let(:source_project) do + create(:project) do |fork_project| + fork_project.create_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) + end + end + + let(:merge_request) do + create(:merge_request, + source_project: source_project, source_branch: 'feature', + target_project: project) + end + + let(:source_environment) { create(:environment, project: source_project) } + + before do + create(:deployment, environment: source_environment, ref: 'feature', sha: merge_request.diff_head_sha) + end + + it 'selects deployed environments' do + expect(merge_request.environments).to contain_exactly(source_environment) + end + + context 'with environments on target project' do + let(:target_environment) { create(:environment, project: project) } + + before do + create(:deployment, environment: target_environment, tag: true, sha: merge_request.diff_head_sha) + end + + it 'selects deployed environments' do + expect(merge_request.environments).to contain_exactly(source_environment, target_environment) + end + end + end + + context 'without a diff_head_commit' do + before do + expect(merge_request).to receive(:diff_head_commit).and_return(nil) + end + + it 'returns an empty array' do + expect(merge_request.environments).to be_empty + end + end + end + describe "#reload_diff" do let(:note) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject) } let(:commit) { subject.project.commit(sample_commit.id) } - it "reloads the diff content" do - expect(subject.merge_request_diff).to receive(:reload_content) - + it "does not change existing merge request diff" do + expect(subject.merge_request_diff).not_to receive(:save_git_content) subject.reload_diff end + it "creates new merge request diff" do + expect { subject.reload_diff }.to change { subject.merge_request_diffs.count }.by(1) + end + it "executs diff cache service" do expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject) @@ -694,13 +950,15 @@ describe MergeRequest, models: true do it "updates diff note positions" do old_diff_refs = subject.diff_refs - merge_request_diff = subject.merge_request_diff - # Update merge_request_diff so that #diff_refs will return commit.diff_refs - allow(merge_request_diff).to receive(:reload_content) do - merge_request_diff.base_commit_sha = commit.parent_id - merge_request_diff.start_commit_sha = commit.parent_id - merge_request_diff.head_commit_sha = commit.sha + allow(subject).to receive(:create_merge_request_diff) do + subject.merge_request_diffs.create( + base_commit_sha: commit.parent_id, + start_commit_sha: commit.parent_id, + head_commit_sha: commit.sha + ) + + subject.merge_request_diff(true) end expect(Notes::DiffPositionUpdateService).to receive(:new).with( @@ -710,14 +968,31 @@ describe MergeRequest, models: true do new_diff_refs: commit.diff_refs, paths: note.position.paths ).and_call_original - expect_any_instance_of(Notes::DiffPositionUpdateService).to receive(:execute).with(note) + expect_any_instance_of(Notes::DiffPositionUpdateService).to receive(:execute).with(note) expect_any_instance_of(DiffNote).to receive(:save).once subject.reload_diff end end + describe '#branch_merge_base_commit' do + context 'source and target branch exist' do + it { expect(subject.branch_merge_base_commit.sha).to eq('ae73cb07c9eeaf35924a10f713b364d32b2dd34f') } + it { expect(subject.branch_merge_base_commit).to be_a(Commit) } + end + + context 'when the target branch does not exist' do + before do + subject.project.repository.raw_repository.delete_branch(subject.target_branch) + end + + it 'returns nil' do + expect(subject.branch_merge_base_commit).to be_nil + end + end + end + describe "#diff_sha_refs" do context "with diffs" do subject { create(:merge_request, :with_diffs) } @@ -741,4 +1016,314 @@ describe MergeRequest, models: true do end end end + + context "discussion status" do + let(:first_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } + let(:second_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } + let(:third_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } + + before do + allow(subject).to receive(:diff_discussions).and_return([first_discussion, second_discussion, third_discussion]) + end + + describe "#discussions_resolvable?" do + context "when all discussions are unresolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(false) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolvable?).to be false + end + end + + context "when some discussions are unresolvable and some discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + + context "when all discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(true) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + end + + describe "#discussions_resolved?" do + context "when discussions are not resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + + context "when discussions are resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(true) + + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable discussions are resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolved?).to be true + end + end + + context "when some resolvable discussions are not resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + end + end + end + + describe '#conflicts_can_be_resolved_in_ui?' do + def create_merge_request(source_branch) + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| + mr.mark_as_unmergeable + end + end + + it 'returns a falsey value when the MR can be merged without conflicts' do + merge_request = create_merge_request('master') + merge_request.mark_as_mergeable + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR is marked as having conflicts, but has none' do + merge_request = create_merge_request('master') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR has a missing ref after a force push' do + merge_request = create_merge_request('conflict-resolvable') + allow(merge_request.conflicts).to receive(:merge_index).and_raise(Rugged::OdbError) + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the MR does not support new diff notes' do + merge_request = create_merge_request('conflict-resolvable') + merge_request.merge_request_diff.update_attributes(start_commit_sha: nil) + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a large file' do + merge_request = create_merge_request('conflict-too-large') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a binary file' do + merge_request = create_merge_request('conflict-binary-file') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do + merge_request = create_merge_request('conflict-contains-conflict-markers') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do + merge_request = create_merge_request('conflict-missing-side') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey + end + + it 'returns a truthy value when the conflicts are resolvable in the UI' do + merge_request = create_merge_request('conflict-resolvable') + + expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy + end + end + + describe "#forked_source_project_missing?" do + let(:project) { create(:project) } + let(:fork_project) { create(:project, forked_from_project: project) } + let(:user) { create(:user) } + let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + + context "when the fork exists" do + let(:merge_request) do + create(:merge_request, + source_project: fork_project, + target_project: project) + end + + it { expect(merge_request.forked_source_project_missing?).to be_falsey } + end + + context "when the source project is the same as the target project" do + let(:merge_request) { create(:merge_request, source_project: project) } + + it { expect(merge_request.forked_source_project_missing?).to be_falsey } + end + + context "when the fork does not exist" do + let(:merge_request) do + create(:merge_request, + source_project: fork_project, + target_project: project) + end + + it "returns true" do + unlink_project.execute + merge_request.reload + + expect(merge_request.forked_source_project_missing?).to be_truthy + end + end + end + + describe "#closed_without_fork?" do + let(:project) { create(:project) } + let(:fork_project) { create(:project, forked_from_project: project) } + let(:user) { create(:user) } + let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + + context "when the merge request is closed" do + let(:closed_merge_request) do + create(:closed_merge_request, + source_project: fork_project, + target_project: project) + end + + it "returns false if the fork exist" do + expect(closed_merge_request.closed_without_fork?).to be_falsey + end + + it "returns true if the fork does not exist" do + unlink_project.execute + closed_merge_request.reload + + expect(closed_merge_request.closed_without_fork?).to be_truthy + end + end + + context "when the merge request is open" do + let(:open_merge_request) do + create(:merge_request, + source_project: fork_project, + target_project: project) + end + + it "returns false" do + expect(open_merge_request.closed_without_fork?).to be_falsey + end + end + end + + describe '#closed_without_source_project?' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:fork_project) { create(:project, forked_from_project: project, namespace: user.namespace) } + let(:destroy_service) { Projects::DestroyService.new(fork_project, user) } + + context 'when the merge request is closed' do + let(:closed_merge_request) do + create(:closed_merge_request, + source_project: fork_project, + target_project: project) + end + + it 'returns false if the source project exists' do + expect(closed_merge_request.closed_without_source_project?).to be_falsey + end + + it 'returns true if the source project does not exist' do + destroy_service.execute + closed_merge_request.reload + + expect(closed_merge_request.closed_without_source_project?).to be_truthy + end + end + + context 'when the merge request is open' do + it 'returns false' do + expect(subject.closed_without_source_project?).to be_falsey + end + end + end + + describe '#reopenable?' do + context 'when the merge request is closed' do + it 'returns true' do + subject.close + + expect(subject.reopenable?).to be_truthy + end + + context 'forked project' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:fork_project) { create(:project, forked_from_project: project, namespace: user.namespace) } + let(:merge_request) do + create(:closed_merge_request, + source_project: fork_project, + target_project: project) + end + + it 'returns false if unforked' do + Projects::UnlinkForkService.new(fork_project, user).execute + + expect(merge_request.reload.reopenable?).to be_falsey + end + + it 'returns false if the source project is deleted' do + Projects::DestroyService.new(fork_project, user).execute + + expect(merge_request.reload.reopenable?).to be_falsey + end + + it 'returns false if the merge request is merged' do + merge_request.update_attributes(state: 'merged') + + expect(merge_request.reload.reopenable?).to be_falsey + end + end + end + + context 'when the merge request is opened' do + it 'returns false' do + expect(subject.reopenable?).to be_falsey + end + end + end end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index d661dc0e59a..33fe22dd98c 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -20,20 +20,20 @@ describe Milestone, models: true do let(:user) { create(:user) } describe "#title" do - let(:milestone) { create(:milestone, title: "<b>test</b>") } + let(:milestone) { create(:milestone, title: "<b>foo & bar -> 2.2</b>") } it "sanitizes title" do - expect(milestone.title).to eq("test") + expect(milestone.title).to eq("foo & bar -> 2.2") end end describe "unique milestone title per project" do - it "shouldn't accept the same title in a project twice" do + it "does not accept the same title in a project twice" do new_milestone = Milestone.new(project: milestone.project, title: milestone.title) expect(new_milestone).not_to be_valid end - it "should accept the same title in another project" do + it "accepts the same title in another project" do project = build(:project) new_milestone = Milestone.new(project: project, title: milestone.title) @@ -42,29 +42,29 @@ describe Milestone, models: true do end describe "#percent_complete" do - it "should not count open issues" do + it "does not count open issues" do milestone.issues << issue expect(milestone.percent_complete(user)).to eq(0) end - it "should count closed issues" do + it "counts closed issues" do issue.close milestone.issues << issue expect(milestone.percent_complete(user)).to eq(100) end - it "should recover from dividing by zero" do + it "recovers from dividing by zero" do expect(milestone.percent_complete(user)).to eq(0) end end describe "#expires_at" do - it "should be nil when due_date is unset" do + it "is nil when due_date is unset" do milestone.update_attributes(due_date: nil) expect(milestone.expires_at).to be_nil end - it "should not be nil when due_date is set" do + it "is not nil when due_date is set" do milestone.update_attributes(due_date: Date.tomorrow) expect(milestone.expires_at).to be_present end @@ -121,7 +121,7 @@ describe Milestone, models: true do create :merge_request, milestone: milestone end - it 'Should return total count of issues and merge requests assigned to milestone' do + it 'returns total count of issues and merge requests assigned to milestone' do expect(milestone.total_items_count(user)).to eq 2 end end @@ -134,11 +134,11 @@ describe Milestone, models: true do create :issue end - it 'should be true if milestone active and all nested issues closed' do + it 'returns true if milestone active and all nested issues closed' do expect(milestone.can_be_closed?).to be_truthy end - it 'should be false if milestone active and not all nested issues closed' do + it 'returns false if milestone active and not all nested issues closed' do issue.milestone = milestone issue.save diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index a162da0208e..431b3e4435f 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -61,11 +61,11 @@ describe Namespace, models: true do allow(@namespace).to receive(:path_changed?).and_return(true) end - it "should raise error when directory exists" do + it "raises error when directory exists" do expect { @namespace.move_dir }.to raise_error("namespace directory cannot be moved") end - it "should move dir if path changed" do + it "moves dir if path changed" do new_path = @namespace.path + "_new" allow(@namespace).to receive(:path_was).and_return(@namespace.path) allow(@namespace).to receive(:path).and_return(new_path) @@ -93,7 +93,7 @@ describe Namespace, models: true do before { namespace.destroy } - it "should remove its dirs when deleted" do + it "removes its dirs when deleted" do expect(File.exist?(path)).to be(false) end end @@ -114,6 +114,7 @@ describe Namespace, models: true do it "cleans the path and makes sure it's available" do expect(Namespace.clean_path("-john+gitlab-ETC%.git@gmail.com")).to eq("johngitlab-ETC2") + expect(Namespace.clean_path("--%+--valid_*&%name=.git.%.atom.atom.@email.com")).to eq("valid_name") end end end diff --git a/spec/models/network/graph_spec.rb b/spec/models/network/graph_spec.rb new file mode 100644 index 00000000000..b76513d2a3c --- /dev/null +++ b/spec/models/network/graph_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe Network::Graph, models: true do + let(:project) { create(:project) } + let!(:note_on_commit) { create(:note_on_commit, project: project) } + + it '#initialize' do + graph = described_class.new(project, 'refs/heads/master', project.repository.commit, nil) + + expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } ) + end +end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 1243f5420a7..e6b6e7c0634 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Note, models: true do + include RepoHelpers + describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:noteable).touch(true) } @@ -56,18 +58,18 @@ describe Note, models: true do let!(:note) { create(:note_on_commit, note: "+1 from me") } let!(:commit) { note.noteable } - it "should be accessible through #noteable" do + it "is accessible through #noteable" do expect(note.commit_id).to eq(commit.id) expect(note.noteable).to be_a(Commit) expect(note.noteable).to eq(commit) end - it "should save a valid note" do + it "saves a valid note" do expect(note.commit_id).to eq(commit.id) note.noteable == commit end - it "should be recognized by #for_commit?" do + it "is recognized by #for_commit?" do expect(note).to be_for_commit end @@ -83,8 +85,6 @@ describe Note, models: true do @u1 = create(:user) @u2 = create(:user) @u3 = create(:user) - @abilities = Six.new - @abilities << Ability end describe 'read' do @@ -93,9 +93,9 @@ describe Note, models: true do @p2.project_members.create(user: @u3, access_level: ProjectMember::GUEST) end - it { expect(@abilities.allowed?(@u1, :read_note, @p1)).to be_falsey } - it { expect(@abilities.allowed?(@u2, :read_note, @p1)).to be_truthy } - it { expect(@abilities.allowed?(@u3, :read_note, @p1)).to be_falsey } + it { expect(Ability.allowed?(@u1, :read_note, @p1)).to be_falsey } + it { expect(Ability.allowed?(@u2, :read_note, @p1)).to be_truthy } + it { expect(Ability.allowed?(@u3, :read_note, @p1)).to be_falsey } end describe 'write' do @@ -104,9 +104,9 @@ describe Note, models: true do @p2.project_members.create(user: @u3, access_level: ProjectMember::DEVELOPER) end - it { expect(@abilities.allowed?(@u1, :create_note, @p1)).to be_falsey } - it { expect(@abilities.allowed?(@u2, :create_note, @p1)).to be_truthy } - it { expect(@abilities.allowed?(@u3, :create_note, @p1)).to be_falsey } + it { expect(Ability.allowed?(@u1, :create_note, @p1)).to be_falsey } + it { expect(Ability.allowed?(@u2, :create_note, @p1)).to be_truthy } + it { expect(Ability.allowed?(@u3, :create_note, @p1)).to be_falsey } end describe 'admin' do @@ -116,9 +116,9 @@ describe Note, models: true do @p2.project_members.create(user: @u3, access_level: ProjectMember::MASTER) end - it { expect(@abilities.allowed?(@u1, :admin_note, @p1)).to be_falsey } - it { expect(@abilities.allowed?(@u2, :admin_note, @p1)).to be_truthy } - it { expect(@abilities.allowed?(@u3, :admin_note, @p1)).to be_falsey } + it { expect(Ability.allowed?(@u1, :admin_note, @p1)).to be_falsey } + it { expect(Ability.allowed?(@u2, :admin_note, @p1)).to be_truthy } + it { expect(Ability.allowed?(@u3, :admin_note, @p1)).to be_falsey } end end @@ -223,7 +223,7 @@ describe Note, models: true do let(:note) do create :note, noteable: ext_issue, project: ext_proj, - note: "mentioned in issue #{private_issue.to_reference(ext_proj)}", + note: "Mentioned in issue #{private_issue.to_reference(ext_proj)}", system: true end @@ -267,4 +267,81 @@ describe Note, models: true do expect(note.participants).to include(note.author) end end + + describe ".grouped_diff_discussions" do + let!(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let!(:active_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } + let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } + let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } + + let(:active_position2) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: 16, + new_line: 22, + diff_refs: merge_request.diff_refs + ) + end + + let(:outdated_position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 9, + diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs + ) + end + + subject { merge_request.notes.grouped_diff_discussions } + + it "includes active discussions" do + discussions = subject.values + + expect(discussions.count).to eq(2) + expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) + expect(discussions.all?(&:active?)).to be true + + expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) + expect(discussions.last.notes).to eq([active_diff_note3]) + end + + it "doesn't include outdated discussions" do + expect(subject.values.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + end + + it "groups the discussions by line code" do + expect(subject[active_diff_note1.line_code].id).to eq(active_diff_note1.discussion_id) + expect(subject[active_diff_note3.line_code].id).to eq(active_diff_note3.discussion_id) + end + end + + describe "#discussion_id" do + let(:note) { create(:note) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + end end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb new file mode 100644 index 00000000000..8d554a01be5 --- /dev/null +++ b/spec/models/project_feature_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' + +describe ProjectFeature do + let(:project) { create(:project) } + let(:user) { create(:user) } + + describe '#feature_available?' do + let(:features) { %w(issues wiki builds merge_requests snippets) } + + context 'when features are disabled' do + it "returns false" do + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::DISABLED) + expect(project.feature_available?(:issues, user)).to eq(false) + end + end + end + + context 'when features are enabled only for team members' do + it "returns false when user is not a team member" do + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) + expect(project.feature_available?(:issues, user)).to eq(false) + end + end + + it "returns true when user is a team member" do + project.team << [user, :developer] + + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + + it "returns true when user is a member of project group" do + group = create(:group) + project = create(:project, namespace: group) + group.add_developer(user) + + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + + it "returns true if user is an admin" do + user.update_attribute(:admin, true) + + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + end + + context 'when feature is enabled for everyone' do + it "returns true" do + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::ENABLED) + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + end + end + + describe '#*_enabled?' do + let(:features) { %w(wiki builds merge_requests) } + + it "returns false when feature is disabled" do + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::DISABLED) + expect(project.public_send("#{feature}_enabled?")).to eq(false) + end + end + + it "returns true when feature is enabled only for team members" do + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) + expect(project.public_send("#{feature}_enabled?")).to eq(true) + end + end + + it "returns true when feature is enabled for everyone" do + features.each do |feature| + project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::ENABLED) + expect(project.public_send("#{feature}_enabled?")).to eq(true) + end + end + end +end diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb index 2fa6715fcaf..c5ff1941378 100644 --- a/spec/models/project_group_link_spec.rb +++ b/spec/models/project_group_link_spec.rb @@ -11,7 +11,7 @@ describe ProjectGroupLink do it { should validate_presence_of(:project_id) } it { should validate_uniqueness_of(:group_id).scoped_to(:project_id).with_message(/already shared/) } - it { should validate_presence_of(:group_id) } + it { should validate_presence_of(:group) } it { should validate_presence_of(:group_access) } end end diff --git a/spec/models/project_security_spec.rb b/spec/models/project_security_spec.rb deleted file mode 100644 index 2142c7c13ef..00000000000 --- a/spec/models/project_security_spec.rb +++ /dev/null @@ -1,112 +0,0 @@ -require 'spec_helper' - -describe Project, models: true do - describe 'authorization' do - before do - @p1 = create(:project) - - @u1 = create(:user) - @u2 = create(:user) - @u3 = create(:user) - @u4 = @p1.owner - - @abilities = Six.new - @abilities << Ability - end - - let(:guest_actions) { Ability.project_guest_rules } - let(:report_actions) { Ability.project_report_rules } - let(:dev_actions) { Ability.project_dev_rules } - let(:master_actions) { Ability.project_master_rules } - let(:owner_actions) { Ability.project_owner_rules } - - describe "Non member rules" do - it "should deny for non-project users any actions" do - owner_actions.each do |action| - expect(@abilities.allowed?(@u1, action, @p1)).to be_falsey - end - end - end - - describe "Guest Rules" do - before do - @p1.project_members.create(project: @p1, user: @u2, access_level: ProjectMember::GUEST) - end - - it "should allow for project user any guest actions" do - guest_actions.each do |action| - expect(@abilities.allowed?(@u2, action, @p1)).to be_truthy - end - end - end - - describe "Report Rules" do - before do - @p1.project_members.create(project: @p1, user: @u2, access_level: ProjectMember::REPORTER) - end - - it "should allow for project user any report actions" do - report_actions.each do |action| - expect(@abilities.allowed?(@u2, action, @p1)).to be_truthy - end - end - end - - describe "Developer Rules" do - before do - @p1.project_members.create(project: @p1, user: @u2, access_level: ProjectMember::REPORTER) - @p1.project_members.create(project: @p1, user: @u3, access_level: ProjectMember::DEVELOPER) - end - - it "should deny for developer master-specific actions" do - [dev_actions - report_actions].each do |action| - expect(@abilities.allowed?(@u2, action, @p1)).to be_falsey - end - end - - it "should allow for project user any dev actions" do - dev_actions.each do |action| - expect(@abilities.allowed?(@u3, action, @p1)).to be_truthy - end - end - end - - describe "Master Rules" do - before do - @p1.project_members.create(project: @p1, user: @u2, access_level: ProjectMember::DEVELOPER) - @p1.project_members.create(project: @p1, user: @u3, access_level: ProjectMember::MASTER) - end - - it "should deny for developer master-specific actions" do - [master_actions - dev_actions].each do |action| - expect(@abilities.allowed?(@u2, action, @p1)).to be_falsey - end - end - - it "should allow for project user any master actions" do - master_actions.each do |action| - expect(@abilities.allowed?(@u3, action, @p1)).to be_truthy - end - end - end - - describe "Owner Rules" do - before do - @p1.project_members.create(project: @p1, user: @u2, access_level: ProjectMember::DEVELOPER) - @p1.project_members.create(project: @p1, user: @u3, access_level: ProjectMember::MASTER) - end - - it "should deny for masters admin-specific actions" do - [owner_actions - master_actions].each do |action| - expect(@abilities.allowed?(@u2, action, @p1)).to be_falsey - end - end - - it "should allow for project owner any admin actions" do - owner_actions.each do |action| - expect(@abilities.allowed?(@u4, action, @p1)).to be_truthy - end - end - end - end -end diff --git a/spec/models/project_services/asana_service_spec.rb b/spec/models/project_services/asana_service_spec.rb index f3d15f3c1ea..8e5145e824b 100644 --- a/spec/models/project_services/asana_service_spec.rb +++ b/spec/models/project_services/asana_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe AsanaService, models: true do @@ -65,7 +45,7 @@ describe AsanaService, models: true do ) end - it 'should call Asana service to create a story' do + it 'calls Asana service to create a story' do data = create_data_for_commits('Message from commit. related to #123456') expected_message = "#{data[:user_name]} pushed to branch #{data[:ref]} of #{project.name_with_namespace} ( #{data[:commits][0][:url]} ): #{data[:commits][0][:message]}" @@ -76,7 +56,7 @@ describe AsanaService, models: true do @asana.execute(data) end - it 'should call Asana service to create a story and close a task' do + it 'calls Asana service to create a story and close a task' do data = create_data_for_commits('fix #456789') d1 = double('Asana::Task') expect(d1).to receive(:add_comment) @@ -86,7 +66,7 @@ describe AsanaService, models: true do @asana.execute(data) end - it 'should be able to close via url' do + it 'is able to close via url' do data = create_data_for_commits('closes https://app.asana.com/19292/956299/42') d1 = double('Asana::Task') expect(d1).to receive(:add_comment) @@ -96,7 +76,7 @@ describe AsanaService, models: true do @asana.execute(data) end - it 'should allow multiple matches per line' do + it 'allows multiple matches per line' do message = <<-EOF minor bigfix, refactoring, fixed #123 and Closes #456 work on #789 ref https://app.asana.com/19292/956299/42 and closing https://app.asana.com/19292/956299/12 diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb index 17e9361dd5c..4c5acb7990b 100644 --- a/spec/models/project_services/assembla_service_spec.rb +++ b/spec/models/project_services/assembla_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe AssemblaService, models: true do @@ -39,12 +19,12 @@ describe AssemblaService, models: true do token: 'verySecret', subdomain: 'project_name' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://atlas.assembla.com/spaces/project_name/github_tool?secret_key=verySecret' WebMock.stub_request(:post, @api_url) end - it "should call Assembla API" do + it "calls Assembla API" do @assembla_service.execute(@sample_data) expect(WebMock).to have_requested(:post, @api_url).with( body: /#{@sample_data[:before]}.*#{@sample_data[:after]}.*#{project.path}/ diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb index 9ae461f8c2d..d7e1a4e3b6c 100644 --- a/spec/models/project_services/bamboo_service_spec.rb +++ b/spec/models/project_services/bamboo_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe BambooService, models: true do diff --git a/spec/models/project_services/bugzilla_service_spec.rb b/spec/models/project_services/bugzilla_service_spec.rb index a6d9717ccb5..739cc72b2ff 100644 --- a/spec/models/project_services/bugzilla_service_spec.rb +++ b/spec/models/project_services/bugzilla_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe BugzillaService, models: true do diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb index 0866e1532dd..6f65beb79d0 100644 --- a/spec/models/project_services/buildkite_service_spec.rb +++ b/spec/models/project_services/buildkite_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe BuildkiteService, models: true do diff --git a/spec/models/project_services/builds_email_service_spec.rb b/spec/models/project_services/builds_email_service_spec.rb index ca2cd8aa551..0194f9e2563 100644 --- a/spec/models/project_services/builds_email_service_spec.rb +++ b/spec/models/project_services/builds_email_service_spec.rb @@ -1,7 +1,9 @@ require 'spec_helper' describe BuildsEmailService do - let(:data) { Gitlab::BuildDataBuilder.build(create(:ci_build)) } + let(:data) do + Gitlab::DataBuilder::Build.build(create(:ci_build)) + end describe 'Validations' do context 'when service is active' do @@ -39,7 +41,7 @@ describe BuildsEmailService do describe '#test' do it 'sends email' do - data = Gitlab::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::Build.build(create(:ci_build)) subject.recipients = 'test@gitlab.com' expect(BuildEmailWorker).to receive(:perform_async) @@ -49,7 +51,7 @@ describe BuildsEmailService do context 'notify only failed builds is true' do it 'sends email' do - data = Gitlab::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::Build.build(create(:ci_build)) data[:build_status] = "success" subject.recipients = 'test@gitlab.com' diff --git a/spec/models/project_services/campfire_service_spec.rb b/spec/models/project_services/campfire_service_spec.rb index 3e6da42803b..a3b9d084a75 100644 --- a/spec/models/project_services/campfire_service_spec.rb +++ b/spec/models/project_services/campfire_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe CampfireService, models: true do @@ -39,4 +19,62 @@ describe CampfireService, models: true do it { is_expected.not_to validate_presence_of(:token) } end end + + describe "#execute" do + let(:user) { create(:user) } + let(:project) { create(:project) } + + before do + @campfire_service = CampfireService.new + allow(@campfire_service).to receive_messages( + project_id: project.id, + project: project, + service_hook: true, + token: 'verySecret', + subdomain: 'project-name', + room: 'test-room' + ) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) + @rooms_url = 'https://verySecret:X@project-name.campfirenow.com/rooms.json' + @headers = { 'Content-Type' => 'application/json; charset=utf-8' } + end + + it "calls Campfire API to get a list of rooms and speak in a room" do + # make sure a valid list of rooms is returned + body = File.read(Rails.root + 'spec/fixtures/project_services/campfire/rooms.json') + WebMock.stub_request(:get, @rooms_url).to_return( + body: body, + status: 200, + headers: @headers + ) + # stub the speak request with the room id found in the previous request's response + speak_url = 'https://verySecret:X@project-name.campfirenow.com/room/123/speak.json' + WebMock.stub_request(:post, speak_url) + + @campfire_service.execute(@sample_data) + + expect(WebMock).to have_requested(:get, @rooms_url).once + expect(WebMock).to have_requested(:post, speak_url).with( + body: /#{project.path}.*#{@sample_data[:before]}.*#{@sample_data[:after]}/ + ).once + end + + it "calls Campfire API to get a list of rooms but shouldn't speak in a room" do + # return a list of rooms that do not contain a room named 'test-room' + body = File.read(Rails.root + 'spec/fixtures/project_services/campfire/rooms2.json') + WebMock.stub_request(:get, @rooms_url).to_return( + body: body, + status: 200, + headers: @headers + ) + # we want to make sure no request is sent to the /speak endpoint, here is a basic + # regexp that matches this endpoint + speak_url = 'https://verySecret:X@project-name.campfirenow.com/room/.*/speak.json' + + @campfire_service.execute(@sample_data) + + expect(WebMock).to have_requested(:get, @rooms_url).once + expect(WebMock).not_to have_requested(:post, /#{speak_url}/) + end + end end diff --git a/spec/models/project_services/custom_issue_tracker_service_spec.rb b/spec/models/project_services/custom_issue_tracker_service_spec.rb index ff976f8ec59..63320931e76 100644 --- a/spec/models/project_services/custom_issue_tracker_service_spec.rb +++ b/spec/models/project_services/custom_issue_tracker_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe CustomIssueTrackerService, models: true do @@ -45,5 +25,21 @@ describe CustomIssueTrackerService, models: true do it { is_expected.not_to validate_presence_of(:issues_url) } it { is_expected.not_to validate_presence_of(:new_issue_url) } end + + context 'title' do + let(:issue_tracker) { described_class.new(properties: {}) } + + it 'sets a default title' do + issue_tracker.title = nil + + expect(issue_tracker.title).to eq('Custom Issue Tracker') + end + + it 'sets the custom title' do + issue_tracker.title = 'test title' + + expect(issue_tracker.title).to eq('test title') + end + end 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 3a8e67438fc..f13bb1e8adf 100644 --- a/spec/models/project_services/drone_ci_service_spec.rb +++ b/spec/models/project_services/drone_ci_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe DroneCiService, models: true do @@ -84,7 +64,9 @@ describe DroneCiService, models: true do include_context :drone_ci_service let(:user) { create(:user, username: 'username') } - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end it do service_hook = double diff --git a/spec/models/project_services/external_wiki_service_spec.rb b/spec/models/project_services/external_wiki_service_spec.rb index 5fe5ea7d2df..342d86aeca9 100644 --- a/spec/models/project_services/external_wiki_service_spec.rb +++ b/spec/models/project_services/external_wiki_service_spec.rb @@ -1,24 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# build_events :boolean default(FALSE), not null -# - require 'spec_helper' describe ExternalWikiService, models: true do @@ -56,7 +35,7 @@ describe ExternalWikiService, models: true do @service.destroy! end - it 'should replace the wiki url' do + it 'replaces the wiki url' do wiki_path = get_project_wiki_path(project) expect(wiki_path).to match('https://gitlab.com') end diff --git a/spec/models/project_services/flowdock_service_spec.rb b/spec/models/project_services/flowdock_service_spec.rb index b7e627e6518..d6db02d6e76 100644 --- a/spec/models/project_services/flowdock_service_spec.rb +++ b/spec/models/project_services/flowdock_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe FlowdockService, models: true do @@ -52,12 +32,12 @@ describe FlowdockService, models: true do service_hook: true, token: 'verySecret' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://api.flowdock.com/v1/messages' WebMock.stub_request(:post, @api_url) end - it "should call FlowDock API" do + it "calls FlowDock API" do @flowdock_service.execute(@sample_data) @sample_data[:commits].each do |commit| # One request to Flowdock per new commit diff --git a/spec/models/project_services/gemnasium_service_spec.rb b/spec/models/project_services/gemnasium_service_spec.rb index a08f1ac229f..529044d1d8b 100644 --- a/spec/models/project_services/gemnasium_service_spec.rb +++ b/spec/models/project_services/gemnasium_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe GemnasiumService, models: true do @@ -55,9 +35,9 @@ describe GemnasiumService, models: true do token: 'verySecret', api_key: 'GemnasiumUserApiKey' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) end - it "should call Gemnasium service" do + it "calls Gemnasium service" do expect(Gemnasium::GitlabService).to receive(:execute).with(an_instance_of(Hash)).once @gemnasium_service.execute(@sample_data) end diff --git a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb index 7a1f106d6e3..652804fb444 100644 --- a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb +++ b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe GitlabIssueTrackerService, models: true do @@ -54,7 +34,7 @@ describe GitlabIssueTrackerService, models: true do @service.destroy! end - it 'should give the correct path' do + it 'gives the correct path' do expect(@service.project_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues") expect(@service.new_issue_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/new") expect(@service.issue_url(432)).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/432") @@ -71,7 +51,7 @@ describe GitlabIssueTrackerService, models: true do @service.destroy! end - it 'should give the correct path' do + it 'gives the correct path' do expect(@service.project_path).to eq("/gitlab/root/#{project.path_with_namespace}/issues") expect(@service.new_issue_path).to eq("/gitlab/root/#{project.path_with_namespace}/issues/new") expect(@service.issue_path(432)).to eq("/gitlab/root/#{project.path_with_namespace}/issues/432") diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index 62ae5f6cf74..26dd95bdfec 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe HipchatService, models: true do @@ -48,7 +28,9 @@ describe HipchatService, models: true do let(:project_name) { project.name_with_namespace.gsub(/\s/, '') } let(:token) { 'verySecret' } let(:server_url) { 'https://hipchat.example.com'} - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end before(:each) do allow(hipchat).to receive_messages( @@ -61,7 +43,7 @@ describe HipchatService, models: true do WebMock.stub_request(:post, api_url) end - it 'should test and return errors' do + it 'tests and return errors' do allow(hipchat).to receive(:execute).and_raise(StandardError, 'no such room') result = hipchat.test(push_sample_data) @@ -69,7 +51,7 @@ describe HipchatService, models: true do expect(result[:result].to_s).to eq('no such room') end - it 'should use v1 if version is provided' do + it 'uses v1 if version is provided' do allow(hipchat).to receive(:api_version).and_return('v1') expect(HipChat::Client).to receive(:new).with( token, @@ -79,7 +61,7 @@ describe HipchatService, models: true do hipchat.execute(push_sample_data) end - it 'should use v2 as the version when nothing is provided' do + it 'uses v2 as the version when nothing is provided' do allow(hipchat).to receive(:api_version).and_return('') expect(HipChat::Client).to receive(:new).with( token, @@ -90,13 +72,13 @@ describe HipchatService, models: true do end context 'push events' do - it "should call Hipchat API for push events" do + it "calls Hipchat API for push events" do hipchat.execute(push_sample_data) expect(WebMock).to have_requested(:post, api_url).once end - it "should create a push message" do + it "creates a push message" do message = hipchat.send(:create_push_message, push_sample_data) push_sample_data[:object_attributes] @@ -108,15 +90,23 @@ describe HipchatService, models: true do end context 'tag_push events' do - let(:push_sample_data) { Gitlab::PushDataBuilder.build(project, user, Gitlab::Git::BLANK_SHA, '1' * 40, 'refs/tags/test', []) } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build( + project, + user, + Gitlab::Git::BLANK_SHA, + '1' * 40, + 'refs/tags/test', + []) + end - it "should call Hipchat API for tag push events" do + it "calls Hipchat API for tag push events" do hipchat.execute(push_sample_data) expect(WebMock).to have_requested(:post, api_url).once end - it "should create a tag push message" do + it "creates a tag push message" do message = hipchat.send(:create_push_message, push_sample_data) push_sample_data[:object_attributes] @@ -131,13 +121,13 @@ describe HipchatService, models: true do let(:issue_service) { Issues::CreateService.new(project, user) } let(:issues_sample_data) { issue_service.hook_data(issue, 'open') } - it "should call Hipchat API for issue events" do + it "calls Hipchat API for issue events" do hipchat.execute(issues_sample_data) expect(WebMock).to have_requested(:post, api_url).once end - it "should create an issue message" do + it "creates an issue message" do message = hipchat.send(:create_issue_message, issues_sample_data) obj_attr = issues_sample_data[:object_attributes] @@ -154,13 +144,13 @@ describe HipchatService, models: true do let(:merge_service) { MergeRequests::CreateService.new(project, user) } let(:merge_sample_data) { merge_service.hook_data(merge_request, 'open') } - it "should call Hipchat API for merge requests events" do + it "calls Hipchat API for merge requests events" do hipchat.execute(merge_sample_data) expect(WebMock).to have_requested(:post, api_url).once end - it "should create a merge request message" do + it "creates a merge request message" do message = hipchat.send(:create_merge_request_message, merge_sample_data) @@ -184,8 +174,8 @@ describe HipchatService, models: true do note: 'a comment on a commit') end - it "should call Hipchat API for commit comment events" do - data = Gitlab::NoteDataBuilder.build(commit_note, user) + it "calls Hipchat API for commit comment events" do + data = Gitlab::DataBuilder::Note.build(commit_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -216,8 +206,8 @@ describe HipchatService, models: true do note: "merge request note") end - it "should call Hipchat API for merge request comment events" do - data = Gitlab::NoteDataBuilder.build(merge_request_note, user) + it "calls Hipchat API for merge request comment events" do + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -243,8 +233,8 @@ describe HipchatService, models: true do note: "issue note") end - it "should call Hipchat API for issue comment events" do - data = Gitlab::NoteDataBuilder.build(issue_note, user) + it "calls Hipchat API for issue comment events" do + data = Gitlab::DataBuilder::Note.build(issue_note, user) hipchat.execute(data) message = hipchat.send(:create_message, data) @@ -269,8 +259,8 @@ describe HipchatService, models: true do note: "snippet note") end - it "should call Hipchat API for snippet comment events" do - data = Gitlab::NoteDataBuilder.build(snippet_note, user) + it "calls Hipchat API for snippet comment events" do + data = Gitlab::DataBuilder::Note.build(snippet_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -291,19 +281,20 @@ describe HipchatService, models: true do end context 'build events' do - let(:build) { create(:ci_build) } - let(:data) { Gitlab::BuildDataBuilder.build(build) } + let(:pipeline) { create(:ci_empty_pipeline) } + let(:build) { create(:ci_build, pipeline: pipeline) } + let(:data) { Gitlab::DataBuilder::Build.build(build.reload) } context 'for failed' do before { build.drop } - it "should call Hipchat API" do + it "calls Hipchat API" do hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once end - it "should create a build message" do + it "creates a build message" do message = hipchat.send(:create_build_message, data) project_url = project.web_url @@ -325,13 +316,13 @@ describe HipchatService, models: true do build.success end - it "should call Hipchat API" do + it "calls Hipchat API" do hipchat.notify_only_broken_builds = false hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once end - it "should notify only broken" do + it "notifies only broken" do hipchat.notify_only_broken_builds = true hipchat.execute(data) expect(WebMock).not_to have_requested(:post, api_url).once diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index 4ee022a5171..f8c45b37561 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' require 'socket' require 'json' @@ -46,32 +26,35 @@ describe IrkerService, models: true do let(:irker) { IrkerService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end let(:recipients) { '#commits irc://test.net/#test ftp://bad' } let(:colorize_messages) { '1' } before do + @irker_server = TCPServer.new 'localhost', 0 + allow(irker).to receive_messages( active: true, project: project, project_id: project.id, service_hook: true, - server_host: 'localhost', - server_port: 6659, + server_host: @irker_server.addr[2], + server_port: @irker_server.addr[1], default_irc_uri: 'irc://chat.freenode.net/', recipients: recipients, colorize_messages: colorize_messages) irker.valid? - @irker_server = TCPServer.new 'localhost', 6659 end after do @irker_server.close end - it 'should send valid JSON messages to an Irker listener' do + it 'sends valid JSON messages to an Irker listener' do irker.execute(sample_data) conn = @irker_server.accept diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 5a97cf370da..b48a3176007 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe JiraService, models: true do @@ -66,7 +46,7 @@ describe JiraService, models: true do password: 'gitlab_jira_password' ) @jira_service.save # will build API URL, as api_url was not specified above - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) # https://github.com/bblimke/webmock#request-with-basic-authentication @api_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions' @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment' @@ -75,7 +55,7 @@ describe JiraService, models: true do WebMock.stub_request(:post, @comment_url) end - it "should call JIRA API" do + it "calls JIRA API" do @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @comment_url).with( @@ -128,7 +108,7 @@ describe JiraService, models: true do expect(@jira_service.api_url).to eq("http://jira_edited.example.com/rest/api/2") end - it "should reset password if url changed, even if setter called multiple times" do + it "resets password if url changed, even if setter called multiple times" do @jira_service.api_url = 'http://jira1.example.com/rest/api/2' @jira_service.api_url = 'http://jira1.example.com/rest/api/2' @jira_service.save @@ -181,7 +161,7 @@ describe JiraService, models: true do @service.destroy! end - it 'should be initialized' do + it 'is initialized' do expect(@service.title).to eq('JIRA') expect(@service.description).to eq("Jira issue tracker") end @@ -197,7 +177,7 @@ describe JiraService, models: true do @service.destroy! end - it "should be correct" do + it "is correct" do expect(@service.title).to eq('Jira One') expect(@service.description).to eq('Jira One issue tracker') end @@ -225,7 +205,7 @@ describe JiraService, models: true do @service.destroy! end - it 'should be prepopulated with the settings' do + it 'is prepopulated with the settings' do expect(@service.properties["project_url"]).to eq('http://jira.sample/projects/project_a') expect(@service.properties["issues_url"]).to eq("http://jira.sample/issues/:id") expect(@service.properties["new_issue_url"]).to eq("http://jira.sample/projects/project_a/issues/new") diff --git a/spec/models/project_services/pivotaltracker_service_spec.rb b/spec/models/project_services/pivotaltracker_service_spec.rb index f37edd4d970..45b2f1068bf 100644 --- a/spec/models/project_services/pivotaltracker_service_spec.rb +++ b/spec/models/project_services/pivotaltracker_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe PivotaltrackerService, models: true do @@ -39,4 +19,75 @@ describe PivotaltrackerService, models: true do it { is_expected.not_to validate_presence_of(:token) } end end + + describe 'Execute' do + let(:service) do + PivotaltrackerService.new.tap do |service| + service.token = 'secret_api_token' + end + end + + let(:url) { PivotaltrackerService::API_ENDPOINT } + + def push_data(branch: 'master') + { + object_kind: 'push', + ref: "refs/heads/#{branch}", + commits: [ + { + id: '21c12ea', + author: { + name: 'Some User' + }, + url: 'https://example.com/commit', + message: 'commit message', + } + ] + } + end + + before do + WebMock.stub_request(:post, url) + end + + it 'should post correct message' do + service.execute(push_data) + expect(WebMock).to have_requested(:post, url).with( + body: { + 'source_commit' => { + 'commit_id' => '21c12ea', + 'author' => 'Some User', + 'url' => 'https://example.com/commit', + 'message' => 'commit message' + } + }, + headers: { + 'Content-Type' => 'application/json', + 'X-TrackerToken' => 'secret_api_token' + } + ).once + end + + context 'when allowed branches is specified' do + let(:service) do + super().tap do |service| + service.restrict_to_branch = 'master,v10' + end + end + + it 'should post message if branch is in the list' do + service.execute(push_data(branch: 'master')) + service.execute(push_data(branch: 'v10')) + + expect(WebMock).to have_requested(:post, url).twice + end + + it 'should not post message if branch is not in the list' do + service.execute(push_data(branch: 'mas')) + service.execute(push_data(branch: 'v11')) + + expect(WebMock).not_to have_requested(:post, url) + end + end + end end diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb index 555d9757b47..8fc92a9ab51 100644 --- a/spec/models/project_services/pushover_service_spec.rb +++ b/spec/models/project_services/pushover_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe PushoverService, models: true do @@ -48,7 +28,9 @@ describe PushoverService, models: true do let(:pushover) { PushoverService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end let(:api_key) { 'verySecret' } let(:user_key) { 'verySecret' } @@ -72,7 +54,7 @@ describe PushoverService, models: true do WebMock.stub_request(:post, api_url) end - it 'should call Pushover API' do + it 'calls Pushover API' do pushover.execute(sample_data) expect(WebMock).to have_requested(:post, api_url).once diff --git a/spec/models/project_services/redmine_service_spec.rb b/spec/models/project_services/redmine_service_spec.rb index 7d14f6e8280..b8679cd2563 100644 --- a/spec/models/project_services/redmine_service_spec.rb +++ b/spec/models/project_services/redmine_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe RedmineService, models: true do diff --git a/spec/models/project_services/slack_service/build_message_spec.rb b/spec/models/project_services/slack_service/build_message_spec.rb index 7fcfdf0eacd..452f4e2782c 100644 --- a/spec/models/project_services/slack_service/build_message_spec.rb +++ b/spec/models/project_services/slack_service/build_message_spec.rb @@ -10,7 +10,7 @@ describe SlackService::BuildMessage do tag: false, project_name: 'project_name', - project_url: 'somewhere.com', + project_url: 'example.gitlab.com', commit: { status: status, @@ -20,42 +20,38 @@ describe SlackService::BuildMessage do } end - context 'succeeded' do + let(:message) { build_message } + + context 'build succeeded' do let(:status) { 'success' } let(:color) { 'good' } let(:duration) { 10 } - + let(:message) { build_message('passed') } + it 'returns a message with information about succeeded build' do - message = '<somewhere.com|project_name>: Commit <somewhere.com/commit/97de212e80737a608d939f648d959671fb0a0142/builds|97de212e> of <somewhere.com/commits/develop|develop> branch by hacker passed in 10 seconds' expect(subject.pretext).to be_empty expect(subject.fallback).to eq(message) expect(subject.attachments).to eq([text: message, color: color]) end end - context 'failed' do + context 'build failed' do let(:status) { 'failed' } let(:color) { 'danger' } let(:duration) { 10 } it 'returns a message with information about failed build' do - message = '<somewhere.com|project_name>: Commit <somewhere.com/commit/97de212e80737a608d939f648d959671fb0a0142/builds|97de212e> of <somewhere.com/commits/develop|develop> branch by hacker failed in 10 seconds' expect(subject.pretext).to be_empty expect(subject.fallback).to eq(message) expect(subject.attachments).to eq([text: message, color: color]) end - end - - describe '#seconds_name' do - let(:status) { 'failed' } - let(:color) { 'danger' } - let(:duration) { 1 } + end - it 'returns seconds as singular when there is only one' do - message = '<somewhere.com|project_name>: Commit <somewhere.com/commit/97de212e80737a608d939f648d959671fb0a0142/builds|97de212e> of <somewhere.com/commits/develop|develop> branch by hacker failed in 1 second' - expect(subject.pretext).to be_empty - expect(subject.fallback).to eq(message) - expect(subject.attachments).to eq([text: message, color: color]) - end + def build_message(status_text = status) + "<example.gitlab.com|project_name>:" \ + " Commit <example.gitlab.com/commit/" \ + "97de212e80737a608d939f648d959671fb0a0142/builds|97de212e>" \ + " of <example.gitlab.com/commits/develop|develop> branch" \ + " by hacker #{status_text} in #{duration} #{'second'.pluralize(duration)}" end end diff --git a/spec/models/project_services/slack_service/issue_message_spec.rb b/spec/models/project_services/slack_service/issue_message_spec.rb index 0f8889bdf3c..98c36ec088d 100644 --- a/spec/models/project_services/slack_service/issue_message_spec.rb +++ b/spec/models/project_services/slack_service/issue_message_spec.rb @@ -7,7 +7,7 @@ describe SlackService::IssueMessage, models: true do { user: { name: 'Test User', - username: 'Test User' + username: 'test.user' }, project_name: 'project_name', project_url: 'somewhere.com', @@ -40,7 +40,7 @@ describe SlackService::IssueMessage, models: true do context 'open' do it 'returns a message regarding opening of issues' do expect(subject.pretext).to eq( - '<somewhere.com|[project_name>] Issue opened by Test User') + '<somewhere.com|[project_name>] Issue opened by test.user') expect(subject.attachments).to eq([ { title: "#100 Issue title", @@ -60,7 +60,7 @@ describe SlackService::IssueMessage, models: true do it 'returns a message regarding closing of issues' do expect(subject.pretext). to eq( - '<somewhere.com|[project_name>] Issue <url|#100 Issue title> closed by Test User') + '<somewhere.com|[project_name>] Issue <url|#100 Issue title> closed by test.user') expect(subject.attachments).to be_empty end end diff --git a/spec/models/project_services/slack_service/merge_message_spec.rb b/spec/models/project_services/slack_service/merge_message_spec.rb index 224c7ceabe8..c5c052d9af1 100644 --- a/spec/models/project_services/slack_service/merge_message_spec.rb +++ b/spec/models/project_services/slack_service/merge_message_spec.rb @@ -7,7 +7,7 @@ describe SlackService::MergeMessage, models: true do { user: { name: 'Test User', - username: 'Test User' + username: 'test.user' }, project_name: 'project_name', project_url: 'somewhere.com', @@ -31,7 +31,7 @@ describe SlackService::MergeMessage, models: true do context 'open' do it 'returns a message regarding opening of merge requests' do expect(subject.pretext).to eq( - 'Test User opened <somewhere.com/merge_requests/100|merge request !100> '\ + 'test.user opened <somewhere.com/merge_requests/100|merge request !100> '\ 'in <somewhere.com|project_name>: *Issue title*') expect(subject.attachments).to be_empty end @@ -43,7 +43,7 @@ describe SlackService::MergeMessage, models: true do end it 'returns a message regarding closing of merge requests' do expect(subject.pretext).to eq( - 'Test User closed <somewhere.com/merge_requests/100|merge request !100> '\ + 'test.user closed <somewhere.com/merge_requests/100|merge request !100> '\ 'in <somewhere.com|project_name>: *Issue title*') expect(subject.attachments).to be_empty end diff --git a/spec/models/project_services/slack_service/note_message_spec.rb b/spec/models/project_services/slack_service/note_message_spec.rb index 379c3e1219c..38cfe4ad3e3 100644 --- a/spec/models/project_services/slack_service/note_message_spec.rb +++ b/spec/models/project_services/slack_service/note_message_spec.rb @@ -7,7 +7,7 @@ describe SlackService::NoteMessage, models: true do @args = { user: { name: 'Test User', - username: 'username', + username: 'test.user', avatar_url: 'http://fakeavatar' }, project_name: 'project_name', @@ -37,7 +37,7 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on commits' do message = SlackService::NoteMessage.new(@args) - expect(message.pretext).to eq("Test User commented on " \ + expect(message.pretext).to eq("test.user commented on " \ "<url|commit 5f163b2b> in <somewhere.com|project_name>: " \ "*Added a commit message*") expected_attachments = [ @@ -60,9 +60,10 @@ describe SlackService::NoteMessage, models: true do title: "merge request title\ndetails\n" } end + it 'returns a message regarding notes on a merge request' do message = SlackService::NoteMessage.new(@args) - expect(message.pretext).to eq("Test User commented on " \ + expect(message.pretext).to eq("test.user commented on " \ "<url|merge request !30> in <somewhere.com|project_name>: " \ "*merge request title*") expected_attachments = [ @@ -89,7 +90,7 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on an issue' do message = SlackService::NoteMessage.new(@args) expect(message.pretext).to eq( - "Test User commented on " \ + "test.user commented on " \ "<url|issue #20> in <somewhere.com|project_name>: " \ "*issue title*") expected_attachments = [ @@ -114,7 +115,7 @@ describe SlackService::NoteMessage, models: true do it 'returns a message regarding notes on a project snippet' do message = SlackService::NoteMessage.new(@args) - expect(message.pretext).to eq("Test User commented on " \ + expect(message.pretext).to eq("test.user commented on " \ "<url|snippet #5> in <somewhere.com|project_name>: " \ "*snippet title*") expected_attachments = [ diff --git a/spec/models/project_services/slack_service/pipeline_message_spec.rb b/spec/models/project_services/slack_service/pipeline_message_spec.rb new file mode 100644 index 00000000000..babb3909f56 --- /dev/null +++ b/spec/models/project_services/slack_service/pipeline_message_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe SlackService::PipelineMessage do + subject { SlackService::PipelineMessage.new(args) } + + let(:args) do + { + object_attributes: { + id: 123, + sha: '97de212e80737a608d939f648d959671fb0a0142', + tag: false, + ref: 'develop', + status: status, + duration: duration + }, + project: { path_with_namespace: 'project_name', + web_url: 'example.gitlab.com' }, + commit: { author_name: 'hacker' } + } + end + + let(:message) { build_message } + + context 'pipeline succeeded' do + let(:status) { 'success' } + let(:color) { 'good' } + let(:duration) { 10 } + let(:message) { build_message('passed') } + + it 'returns a message with information about succeeded build' do + expect(subject.pretext).to be_empty + expect(subject.fallback).to eq(message) + expect(subject.attachments).to eq([text: message, color: color]) + end + end + + context 'pipeline failed' do + let(:status) { 'failed' } + let(:color) { 'danger' } + let(:duration) { 10 } + + it 'returns a message with information about failed build' do + expect(subject.pretext).to be_empty + expect(subject.fallback).to eq(message) + expect(subject.attachments).to eq([text: message, color: color]) + end + end + + def build_message(status_text = status) + "<example.gitlab.com|project_name>:" \ + " Pipeline <example.gitlab.com/pipelines/123|97de212e>" \ + " of <example.gitlab.com/commits/develop|develop> branch" \ + " by hacker #{status_text} in #{duration} #{'second'.pluralize(duration)}" + end +end diff --git a/spec/models/project_services/slack_service/push_message_spec.rb b/spec/models/project_services/slack_service/push_message_spec.rb index cda9ee670b0..17cd05e24f1 100644 --- a/spec/models/project_services/slack_service/push_message_spec.rb +++ b/spec/models/project_services/slack_service/push_message_spec.rb @@ -9,7 +9,7 @@ describe SlackService::PushMessage, models: true do before: 'before', project_name: 'project_name', ref: 'refs/heads/master', - user_name: 'user_name', + user_name: 'test.user', project_url: 'url' } end @@ -26,7 +26,7 @@ describe SlackService::PushMessage, models: true do it 'returns a message regarding pushes' do expect(subject.pretext).to eq( - 'user_name pushed to branch <url/commits/master|master> of '\ + 'test.user pushed to branch <url/commits/master|master> of '\ '<url|project_name> (<url/compare/before...after|Compare changes>)' ) expect(subject.attachments).to eq([ @@ -46,13 +46,13 @@ describe SlackService::PushMessage, models: true do before: Gitlab::Git::BLANK_SHA, project_name: 'project_name', ref: 'refs/tags/new_tag', - user_name: 'user_name', + user_name: 'test.user', project_url: 'url' } end it 'returns a message regarding pushes' do - expect(subject.pretext).to eq('user_name pushed new tag ' \ + expect(subject.pretext).to eq('test.user pushed new tag ' \ '<url/commits/new_tag|new_tag> to ' \ '<url|project_name>') expect(subject.attachments).to be_empty @@ -66,7 +66,7 @@ describe SlackService::PushMessage, models: true do it 'returns a message regarding a new branch' do expect(subject.pretext).to eq( - 'user_name pushed new branch <url/commits/master|master> to '\ + 'test.user pushed new branch <url/commits/master|master> to '\ '<url|project_name>' ) expect(subject.attachments).to be_empty @@ -80,7 +80,7 @@ describe SlackService::PushMessage, models: true do it 'returns a message regarding a removed branch' do expect(subject.pretext).to eq( - 'user_name removed branch master from <url|project_name>' + 'test.user removed branch master from <url|project_name>' ) expect(subject.attachments).to be_empty end diff --git a/spec/models/project_services/slack_service/wiki_page_message_spec.rb b/spec/models/project_services/slack_service/wiki_page_message_spec.rb index 46dedb66c7c..093911598b0 100644 --- a/spec/models/project_services/slack_service/wiki_page_message_spec.rb +++ b/spec/models/project_services/slack_service/wiki_page_message_spec.rb @@ -7,7 +7,7 @@ describe SlackService::WikiPageMessage, models: true do { user: { name: 'Test User', - username: 'Test User' + username: 'test.user' }, project_name: 'project_name', project_url: 'somewhere.com', @@ -25,7 +25,7 @@ describe SlackService::WikiPageMessage, models: true do it 'returns a message that a new wiki page was created' do expect(subject.pretext).to eq( - 'Test User created <url|wiki page> in <somewhere.com|project_name>: '\ + 'test.user created <url|wiki page> in <somewhere.com|project_name>: '\ '*Wiki page title*') end end @@ -35,7 +35,7 @@ describe SlackService::WikiPageMessage, models: true do it 'returns a message that a wiki page was updated' do expect(subject.pretext).to eq( - 'Test User edited <url|wiki page> in <somewhere.com|project_name>: '\ + 'test.user edited <url|wiki page> in <somewhere.com|project_name>: '\ '*Wiki page title*') end end @@ -47,7 +47,7 @@ describe SlackService::WikiPageMessage, models: true do context 'when :action == "create"' do before { args[:object_attributes][:action] = 'create' } - it 'it returns the attachment for a new wiki page' do + it 'returns the attachment for a new wiki page' do expect(subject.attachments).to eq([ { text: "Wiki page description", @@ -60,7 +60,7 @@ describe SlackService::WikiPageMessage, models: true do context 'when :action == "update"' do before { args[:object_attributes][:action] = 'update' } - it 'it returns the attachment for an updated wiki page' do + it 'returns the attachment for an updated wiki page' do expect(subject.attachments).to eq([ { text: "Wiki page description", diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb index df511b1bc4c..c07a70a8069 100644 --- a/spec/models/project_services/slack_service_spec.rb +++ b/spec/models/project_services/slack_service_spec.rb @@ -1,26 +1,9 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe SlackService, models: true do + let(:slack) { SlackService.new } + let(:webhook_url) { 'https://example.gitlab.com/' } + describe "Associations" do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -42,13 +25,14 @@ describe SlackService, models: true do end describe "Execute" do - let(:slack) { SlackService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } - let(:webhook_url) { 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' } let(:username) { 'slack_username' } - let(:channel) { 'slack_channel' } + let(:channel) { 'slack_channel' } + + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end before do allow(slack).to receive_messages( @@ -93,31 +77,31 @@ describe SlackService, models: true do @wiki_page_sample_data = wiki_page_service.hook_data(@wiki_page, 'create') end - it "should call Slack API for push events" do + it "calls Slack API for push events" do slack.execute(push_sample_data) expect(WebMock).to have_requested(:post, webhook_url).once end - it "should call Slack API for issue events" do + it "calls Slack API for issue events" do slack.execute(@issues_sample_data) expect(WebMock).to have_requested(:post, webhook_url).once end - it "should call Slack API for merge requests events" do + it "calls Slack API for merge requests events" do slack.execute(@merge_sample_data) expect(WebMock).to have_requested(:post, webhook_url).once end - it "should call Slack API for wiki page events" do + it "calls Slack API for wiki page events" do slack.execute(@wiki_page_sample_data) expect(WebMock).to have_requested(:post, webhook_url).once end - it 'should use the username as an option for slack when configured' do + it 'uses the username as an option for slack when configured' do allow(slack).to receive(:username).and_return(username) expect(Slack::Notifier).to receive(:new). with(webhook_url, username: username). @@ -128,7 +112,7 @@ describe SlackService, models: true do slack.execute(push_sample_data) end - it 'should use the channel as an option when it is configured' do + it 'uses the channel as an option when it is configured' do allow(slack).to receive(:channel).and_return(channel) expect(Slack::Notifier).to receive(:new). with(webhook_url, channel: channel). @@ -195,7 +179,7 @@ describe SlackService, models: true do it "uses the right channel" do slack.update_attributes(note_channel: "random") - note_data = Gitlab::NoteDataBuilder.build(issue_note, user) + note_data = Gitlab::DataBuilder::Note.build(issue_note, user) expect(Slack::Notifier).to receive(:new). with(webhook_url, channel: "random"). @@ -210,10 +194,8 @@ describe SlackService, models: true do end describe "Note events" do - let(:slack) { SlackService.new } let(:user) { create(:user) } let(:project) { create(:project, creator_id: user.id) } - let(:webhook_url) { 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' } before do allow(slack).to receive_messages( @@ -234,8 +216,8 @@ describe SlackService, models: true do note: 'a comment on a commit') end - it "should call Slack API for commit comment events" do - data = Gitlab::NoteDataBuilder.build(commit_note, user) + it "calls Slack API for commit comment events" do + data = Gitlab::DataBuilder::Note.build(commit_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -248,8 +230,8 @@ describe SlackService, models: true do note: "merge request note") end - it "should call Slack API for merge request comment events" do - data = Gitlab::NoteDataBuilder.build(merge_request_note, user) + it "calls Slack API for merge request comment events" do + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -261,8 +243,8 @@ describe SlackService, models: true do create(:note_on_issue, project: project, note: "issue note") end - it "should call Slack API for issue comment events" do - data = Gitlab::NoteDataBuilder.build(issue_note, user) + it "calls Slack API for issue comment events" do + data = Gitlab::DataBuilder::Note.build(issue_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -275,12 +257,71 @@ describe SlackService, models: true do note: "snippet note") end - it "should call Slack API for snippet comment events" do - data = Gitlab::NoteDataBuilder.build(snippet_note, user) + it "calls Slack API for snippet comment events" do + data = Gitlab::DataBuilder::Note.build(snippet_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once end end end + + describe 'Pipeline events' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, status: status, + sha: project.commit.sha, ref: project.default_branch) + end + + before do + allow(slack).to receive_messages( + project: project, + service_hook: true, + webhook: webhook_url + ) + end + + shared_examples 'call Slack API' do + before do + WebMock.stub_request(:post, webhook_url) + end + + it 'calls Slack API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + slack.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'with failed pipeline' do + let(:status) { 'failed' } + + it_behaves_like 'call Slack API' + end + + context 'with succeeded pipeline' do + let(:status) { 'success' } + + context 'with default to notify_only_broken_pipelines' do + it 'does not call Slack API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + result = slack.execute(data) + + expect(result).to be_falsy + end + end + + context 'with setting notify_only_broken_pipelines to false' do + before do + slack.notify_only_broken_pipelines = false + end + + it_behaves_like 'call Slack API' + end + end + end end diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb index 474715d24c3..f7e878844dc 100644 --- a/spec/models/project_services/teamcity_service_spec.rb +++ b/spec/models/project_services/teamcity_service_spec.rb @@ -1,23 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# - require 'spec_helper' describe TeamcityService, models: true do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e365e4e98b2..67dbcc362f6 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -6,6 +6,7 @@ describe Project, models: true do it { is_expected.to belong_to(:namespace) } it { is_expected.to belong_to(:creator).class_name('User') } it { is_expected.to have_many(:users) } + it { is_expected.to have_many(:services) } it { is_expected.to have_many(:events).dependent(:destroy) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:issues).dependent(:destroy) } @@ -23,6 +24,31 @@ describe Project, models: true do it { is_expected.to have_one(:slack_service).dependent(:destroy) } it { is_expected.to have_one(:pushover_service).dependent(:destroy) } it { is_expected.to have_one(:asana_service).dependent(:destroy) } + it { is_expected.to have_many(:boards).dependent(:destroy) } + it { is_expected.to have_one(:campfire_service).dependent(:destroy) } + it { is_expected.to have_one(:drone_ci_service).dependent(:destroy) } + it { is_expected.to have_one(:emails_on_push_service).dependent(:destroy) } + it { is_expected.to have_one(:builds_email_service).dependent(:destroy) } + it { is_expected.to have_one(:emails_on_push_service).dependent(:destroy) } + it { is_expected.to have_one(:irker_service).dependent(:destroy) } + it { is_expected.to have_one(:pivotaltracker_service).dependent(:destroy) } + it { is_expected.to have_one(:hipchat_service).dependent(:destroy) } + it { is_expected.to have_one(:flowdock_service).dependent(:destroy) } + it { is_expected.to have_one(:assembla_service).dependent(:destroy) } + it { is_expected.to have_one(:gemnasium_service).dependent(:destroy) } + it { is_expected.to have_one(:buildkite_service).dependent(:destroy) } + it { is_expected.to have_one(:bamboo_service).dependent(:destroy) } + it { is_expected.to have_one(:teamcity_service).dependent(:destroy) } + it { is_expected.to have_one(:jira_service).dependent(:destroy) } + it { is_expected.to have_one(:redmine_service).dependent(:destroy) } + it { is_expected.to have_one(:custom_issue_tracker_service).dependent(:destroy) } + it { is_expected.to have_one(:bugzilla_service).dependent(:destroy) } + it { is_expected.to have_one(:gitlab_issue_tracker_service).dependent(:destroy) } + it { is_expected.to have_one(:external_wiki_service).dependent(:destroy) } + it { is_expected.to have_one(:project_feature).dependent(:destroy) } + it { is_expected.to have_one(:import_data).class_name('ProjectImportData').dependent(:destroy) } + it { is_expected.to have_one(:last_event).class_name('Event') } + it { is_expected.to have_one(:forked_from_project).through(:forked_project_link) } it { is_expected.to have_many(:commit_statuses) } it { is_expected.to have_many(:pipelines) } it { is_expected.to have_many(:builds) } @@ -30,12 +56,19 @@ describe Project, models: true do it { is_expected.to have_many(:runners) } it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } + it { is_expected.to have_many(:labels).dependent(:destroy) } + it { is_expected.to have_many(:users_star_projects).dependent(:destroy) } it { is_expected.to have_many(:environments).dependent(:destroy) } it { is_expected.to have_many(:deployments).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } + it { is_expected.to have_many(:releases).dependent(:destroy) } + it { is_expected.to have_many(:lfs_objects_projects).dependent(:destroy) } + it { is_expected.to have_many(:project_group_links).dependent(:destroy) } + it { is_expected.to have_many(:notification_settings).dependent(:destroy) } + it { is_expected.to have_many(:forks).through(:forked_project_links) } describe '#members & #requesters' do - let(:project) { create(:project) } + let(:project) { create(:project, :public) } let(:requester) { create(:user) } let(:developer) { create(:user) } before do @@ -61,6 +94,15 @@ describe Project, models: true do end end end + + describe '#boards' do + it 'raises an error when attempting to add more than one board to the project' do + subject.boards.build + + expect { subject.boards.build }.to raise_error(Project::BoardLimitExceeded, 'Number of permitted boards exceeded') + expect(subject.boards.size).to eq 1 + end + end end describe 'modules' do @@ -69,6 +111,7 @@ describe Project, models: true do it { is_expected.to include_module(Gitlab::ConfigHelper) } it { is_expected.to include_module(Gitlab::ShellAdapter) } it { is_expected.to include_module(Gitlab::VisibilityLevel) } + it { is_expected.to include_module(Gitlab::CurrentSettings) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } end @@ -88,7 +131,7 @@ describe Project, models: true do it { is_expected.to validate_presence_of(:namespace) } it { is_expected.to validate_presence_of(:repository_storage) } - it 'should not allow new projects beyond user limits' do + it 'does not allow new projects beyond user limits' do project2 = build(:project) allow(project2).to receive(:creator).and_return(double(can_create_project?: false, projects_limit: 0).as_null_object) expect(project2).not_to be_valid @@ -97,7 +140,7 @@ describe Project, models: true do describe 'wiki path conflict' do context "when the new path has been used by the wiki of other Project" do - it 'should have an error on the name attribute' do + it 'has an error on the name attribute' do new_project = build_stubbed(:project, namespace_id: project.namespace_id, path: "#{project.path}.wiki") expect(new_project).not_to be_valid @@ -106,7 +149,7 @@ describe Project, models: true do end context "when the new wiki path has been used by the path of other Project" do - it 'should have an error on the name attribute' do + it 'has an error on the name attribute' do project_with_wiki_suffix = create(:project, path: 'foo.wiki') new_project = build_stubbed(:project, namespace_id: project_with_wiki_suffix.namespace_id, path: 'foo') @@ -124,7 +167,7 @@ describe Project, models: true do allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end - it "should not allow repository storages that don't match a label in the configuration" do + it "does not allow repository storages that don't match a label in the configuration" do expect(project2).not_to be_valid expect(project2.errors[:repository_storage].first).to match(/is not included in the list/) end @@ -171,12 +214,12 @@ describe Project, models: true do end describe 'project token' do - it 'should set an random token if none provided' do + it 'sets an random token if none provided' do project = FactoryGirl.create :empty_project, runners_token: '' expect(project.runners_token).not_to eq('') end - it 'should not set an random toke if one provided' do + it 'does not set an random token if one provided' do project = FactoryGirl.create :empty_project, runners_token: 'my-token' expect(project.runners_token).to eq('my-token') end @@ -185,7 +228,6 @@ describe Project, models: true do describe 'Respond to' do it { is_expected.to respond_to(:url_to_repo) } it { is_expected.to respond_to(:repo_exists?) } - it { is_expected.to respond_to(:update_merge_requests) } it { is_expected.to respond_to(:execute_hooks) } it { is_expected.to respond_to(:owner) } it { is_expected.to respond_to(:path_with_namespace) } @@ -224,7 +266,7 @@ describe Project, models: true do end end - it 'should return valid url to repo' do + it 'returns valid url to repo' do project = Project.new(path: 'somewhere') expect(project.url_to_repo).to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + 'somewhere.git') end @@ -245,7 +287,7 @@ describe Project, models: true do end end - describe "#new_issue_address" do + xdescribe "#new_issue_address" do let(:project) { create(:empty_project, path: "somewhere") } let(:user) { create(:user) } @@ -274,20 +316,24 @@ describe Project, models: true do end describe 'last_activity methods' do - let(:project) { create(:project) } - let(:last_event) { double(created_at: Time.now) } + let(:timestamp) { 2.hours.ago } + # last_activity_at gets set to created_at upon creation + let(:project) { create(:project, created_at: timestamp, updated_at: timestamp) } describe 'last_activity' do - it 'should alias last_activity to last_event' do - allow(project).to receive(:last_event).and_return(last_event) + it 'alias last_activity to last_event' do + last_event = create(:event, project: project) + expect(project.last_activity).to eq(last_event) end end describe 'last_activity_date' do it 'returns the creation date of the project\'s last event if present' do - create(:event, project: project) - expect(project.last_activity_at.to_i).to eq(last_event.created_at.to_i) + new_event = create(:event, project: project, created_at: Time.now) + + project.reload + expect(project.last_activity_at.to_i).to eq(new_event.created_at.to_i) end it 'returns the project\'s last update date if it has no events' do @@ -342,26 +388,6 @@ describe Project, models: true do end end - describe '#update_merge_requests' do - let(:project) { create(:project) } - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } - let(:key) { create(:key, user_id: project.owner.id) } - let(:prev_commit_id) { merge_request.commits.last.id } - let(:commit_id) { merge_request.commits.first.id } - - it 'should close merge request if last commit from source branch was pushed to target branch' do - project.update_merge_requests(prev_commit_id, commit_id, "refs/heads/#{merge_request.target_branch}", key.user) - merge_request.reload - expect(merge_request.merged?).to be_truthy - end - - it 'should update merge request commits with new one if pushed to source branch' do - project.update_merge_requests(prev_commit_id, commit_id, "refs/heads/#{merge_request.source_branch}", key.user) - merge_request.reload - expect(merge_request.diff_head_sha).to eq(commit_id) - end - end - describe '.find_with_namespace' do context 'with namespace' do before do @@ -432,11 +458,11 @@ describe Project, models: true do let(:project) { create(:project) } let(:ext_project) { create(:redmine_project) } - it "should be true if used internal tracker" do + it "is true if used internal tracker" do expect(project.default_issues_tracker?).to be_truthy end - it "should be false if used other tracker" do + it "is false if used other tracker" do expect(ext_project.default_issues_tracker?).to be_falsey end end @@ -483,7 +509,7 @@ describe Project, models: true do end describe '#cache_has_external_issue_tracker' do - let(:project) { create(:project) } + let(:project) { create(:project, has_external_issue_tracker: nil) } it 'stores true if there is any external_issue_tracker' do services = double(:service, external_issue_trackers: [RedmineService.new]) @@ -504,6 +530,18 @@ describe Project, models: true do end end + describe '#has_wiki?' do + let(:no_wiki_project) { build(:project, wiki_enabled: false, has_external_wiki: false) } + let(:wiki_enabled_project) { build(:project) } + let(:external_wiki_project) { build(:project, has_external_wiki: true) } + + it 'returns true if project is wiki enabled or has external wiki' do + expect(wiki_enabled_project).to have_wiki + expect(external_wiki_project).to have_wiki + expect(no_wiki_project).not_to have_wiki + end + end + describe '#external_wiki' do let(:project) { create(:project) } @@ -635,12 +673,12 @@ describe Project, models: true do describe '#avatar_type' do let(:project) { create(:project) } - it 'should be true if avatar is image' do + it 'is true if avatar is image' do project.update_attribute(:avatar, 'uploads/avatar.png') expect(project.avatar_type).to be_truthy end - it 'should be false if avatar is html page' do + it 'is false if avatar is html page' do project.update_attribute(:avatar, 'uploads/avatar.html') expect(project.avatar_type).to eq(['only images allowed']) end @@ -683,36 +721,62 @@ describe Project, models: true do end end - describe '#pipeline' do - let(:project) { create :project } - let(:pipeline) { create :ci_pipeline, project: project, ref: 'master' } - - subject { project.pipeline(pipeline.sha, 'master') } + describe '#pipeline_for' do + let(:project) { create(:project) } + let!(:pipeline) { create_pipeline } - it { is_expected.to eq(pipeline) } + shared_examples 'giving the correct pipeline' do + it { is_expected.to eq(pipeline) } - context 'return latest' do - let(:pipeline2) { create :ci_pipeline, project: project, ref: 'master' } + context 'return latest' do + let!(:pipeline2) { create_pipeline } - before do - pipeline - pipeline2 + it { is_expected.to eq(pipeline2) } end + end + + context 'with explicit sha' do + subject { project.pipeline_for('master', pipeline.sha) } + + it_behaves_like 'giving the correct pipeline' + end + + context 'with implicit sha' do + subject { project.pipeline_for('master') } - it { is_expected.to eq(pipeline2) } + it_behaves_like 'giving the correct pipeline' + end + + def create_pipeline + create(:ci_pipeline, + project: project, + ref: 'master', + sha: project.commit('master').sha) end end describe '#builds_enabled' do let(:project) { create :project } - before { project.builds_enabled = true } - subject { project.builds_enabled } it { expect(project.builds_enabled?).to be_truthy } end + describe '.cached_count', caching: true do + let(:group) { create(:group, :public) } + let!(:project1) { create(:empty_project, :public, group: group) } + let!(:project2) { create(:empty_project, :public, group: group) } + + it 'returns total project count' do + expect(Project).to receive(:count).once.and_call_original + + 3.times do + expect(Project.cached_count).to eq(2) + end + end + end + describe '.trending' do let(:group) { create(:group, :public) } let(:project1) { create(:empty_project, :public, group: group) } @@ -724,32 +788,22 @@ describe Project, models: true do end create(:note_on_commit, project: project2) - end - - describe 'without an explicit start date' do - subject { described_class.trending.to_a } - it 'sorts Projects by the amount of notes in descending order' do - expect(subject).to eq([project1, project2]) - end + TrendingProject.refresh! end - describe 'with an explicit start date' do - let(:date) { 2.months.ago } + subject { described_class.trending.to_a } - subject { described_class.trending(date).to_a } + it 'sorts projects by the amount of notes in descending order' do + expect(subject).to eq([project1, project2]) + end - before do - 2.times do - # Little fix for special issue related to Fractional Seconds support for MySQL. - # See: https://github.com/rails/rails/pull/14359/files - create(:note_on_commit, project: project2, created_at: date + 1) - end + it 'does not take system notes into account' do + 10.times do + create(:note_on_commit, project: project2, system: true) end - it 'sorts Projects by the amount of notes in descending order' do - expect(subject).to eq([project2, project1]) - end + expect(described_class.trending.to_a).to eq([project1, project2]) end end @@ -761,7 +815,7 @@ describe Project, models: true do describe 'when a user has access to a project' do before do - project.team.add_user(user, Gitlab::Access::MASTER) + project.add_user(user, Gitlab::Access::MASTER) end it { is_expected.to eq([project]) } @@ -813,16 +867,16 @@ describe Project, models: true do context 'for shared runners disabled' do let(:shared_runners_enabled) { false } - it 'there are no runners available' do + it 'has no runners available' do expect(project.any_runners?).to be_falsey end - it 'there is a specific runner' do + it 'has a specific runner' do project.runners << specific_runner expect(project.any_runners?).to be_truthy end - it 'there is a shared runner, but they are prohibited to use' do + it 'has a shared runner, but they are prohibited to use' do shared_runner expect(project.any_runners?).to be_falsey end @@ -836,7 +890,7 @@ describe Project, models: true do context 'for shared runners enabled' do let(:shared_runners_enabled) { true } - it 'there is a shared runner' do + it 'has a shared runner' do shared_runner expect(project.any_runners?).to be_truthy end @@ -1070,28 +1124,97 @@ describe Project, models: true do end describe '#protected_branch?' do + context 'existing project' do + let(:project) { create(:project) } + + it 'returns true when the branch matches a protected branch via direct match' do + create(:protected_branch, project: project, name: "foo") + + expect(project.protected_branch?('foo')).to eq(true) + end + + it 'returns true when the branch matches a protected branch via wildcard match' do + create(:protected_branch, project: project, name: "production/*") + + expect(project.protected_branch?('production/some-branch')).to eq(true) + end + + it 'returns false when the branch does not match a protected branch via direct match' do + expect(project.protected_branch?('foo')).to eq(false) + end + + it 'returns false when the branch does not match a protected branch via wildcard match' do + create(:protected_branch, project: project, name: "production/*") + + expect(project.protected_branch?('staging/some-branch')).to eq(false) + end + end + + context "new project" do + let(:project) { create(:empty_project) } + + it 'returns false when default_protected_branch is unprotected' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) + + expect(project.protected_branch?('master')).to be false + end + + it 'returns false when default_protected_branch lets developers push' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + expect(project.protected_branch?('master')).to be false + end + + it 'returns true when default_branch_protection does not let developers push but let developer merge branches' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) + + expect(project.protected_branch?('master')).to be true + end + + it 'returns true when default_branch_protection is in full protection' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) + + expect(project.protected_branch?('master')).to be true + end + end + end + + describe '#user_can_push_to_empty_repo?' do let(:project) { create(:empty_project) } + let(:user) { create(:user) } - it 'returns true when the branch matches a protected branch via direct match' do - project.protected_branches.create!(name: 'foo') + it 'returns false when default_branch_protection is in full protection and user is developer' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) - expect(project.protected_branch?('foo')).to eq(true) + expect(project.user_can_push_to_empty_repo?(user)).to be_falsey end - it 'returns true when the branch matches a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + it 'returns false when default_branch_protection only lets devs merge and user is dev' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) - expect(project.protected_branch?('production/some-branch')).to eq(true) + expect(project.user_can_push_to_empty_repo?(user)).to be_falsey end - it 'returns false when the branch does not match a protected branch via direct match' do - expect(project.protected_branch?('foo')).to eq(false) + it 'returns true when default_branch_protection lets devs push and user is developer' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + expect(project.user_can_push_to_empty_repo?(user)).to be_truthy end - it 'returns false when the branch does not match a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + it 'returns true when default_branch_protection is unprotected and user is developer' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) - expect(project.protected_branch?('staging/some-branch')).to eq(false) + expect(project.user_can_push_to_empty_repo?(user)).to be_truthy + end + + it 'returns true when user is master' do + project.team << [user, :master] + + expect(project.user_can_push_to_empty_repo?(user)).to be_truthy end end @@ -1276,6 +1399,68 @@ describe Project, models: true do end end + describe '#lfs_enabled?' do + let(:project) { create(:project) } + + shared_examples 'project overrides group' do + it 'returns true when enabled in project' do + project.update_attribute(:lfs_enabled, true) + + expect(project.lfs_enabled?).to be_truthy + end + + it 'returns false when disabled in project' do + project.update_attribute(:lfs_enabled, false) + + expect(project.lfs_enabled?).to be_falsey + end + + it 'returns the value from the namespace, when no value is set in project' do + expect(project.lfs_enabled?).to eq(project.namespace.lfs_enabled?) + end + end + + context 'LFS disabled in group' do + before do + project.namespace.update_attribute(:lfs_enabled, false) + enable_lfs + end + + it_behaves_like 'project overrides group' + end + + context 'LFS enabled in group' do + before do + project.namespace.update_attribute(:lfs_enabled, true) + enable_lfs + end + + it_behaves_like 'project overrides group' + end + + describe 'LFS disabled globally' do + shared_examples 'it always returns false' do + it do + expect(project.lfs_enabled?).to be_falsey + expect(project.namespace.lfs_enabled?).to be_falsey + end + end + + context 'when no values are set' do + it_behaves_like 'it always returns false' + end + + context 'when all values are set to true' do + before do + project.namespace.update_attribute(:lfs_enabled, true) + project.update_attribute(:lfs_enabled, true) + end + + it_behaves_like 'it always returns false' + end + end + end + describe '.where_paths_in' do context 'without any paths' do it 'returns an empty relation' do @@ -1357,4 +1542,132 @@ describe Project, models: true do expect(shared_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(true) end end + + describe 'change_head' do + let(:project) { create(:project) } + + it 'calls the before_change_head method' do + expect(project.repository).to receive(:before_change_head) + project.change_head(project.default_branch) + end + + it 'creates the new reference with rugged' do + expect(project.repository.rugged.references).to receive(:create).with('HEAD', + "refs/heads/#{project.default_branch}", + force: true) + project.change_head(project.default_branch) + end + + it 'copies the gitattributes' do + expect(project.repository).to receive(:copy_gitattributes).with(project.default_branch) + project.change_head(project.default_branch) + end + + it 'expires the avatar cache' do + expect(project.repository).to receive(:expire_avatar_cache).with(project.default_branch) + project.change_head(project.default_branch) + end + + it 'reloads the default branch' do + expect(project).to receive(:reload_default_branch) + project.change_head(project.default_branch) + end + end + + describe '#pushes_since_gc' do + let(:project) { create(:project) } + + after do + project.reset_pushes_since_gc + end + + context 'without any pushes' do + it 'returns 0' do + expect(project.pushes_since_gc).to eq(0) + end + end + + context 'with a number of pushes' do + it 'returns the number of pushes' do + 3.times { project.increment_pushes_since_gc } + + expect(project.pushes_since_gc).to eq(3) + end + end + end + + describe '#increment_pushes_since_gc' do + let(:project) { create(:project) } + + after do + project.reset_pushes_since_gc + end + + it 'increments the number of pushes since the last GC' do + 3.times { project.increment_pushes_since_gc } + + expect(project.pushes_since_gc).to eq(3) + end + end + + describe '#reset_pushes_since_gc' do + let(:project) { create(:project) } + + after do + project.reset_pushes_since_gc + end + + it 'resets the number of pushes since the last GC' do + 3.times { project.increment_pushes_since_gc } + + project.reset_pushes_since_gc + + expect(project.pushes_since_gc).to eq(0) + end + end + + describe '#environments_for' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + + context 'tagged deployment' do + before do + create(:deployment, environment: environment, ref: '1.0', tag: true, sha: project.commit.id) + end + + it 'returns environment when with_tags is set' do + expect(project.environments_for('master', project.commit, with_tags: true)).to contain_exactly(environment) + end + + it 'does not return environment when no with_tags is set' do + expect(project.environments_for('master', project.commit)).to be_empty + end + + it 'does not return environment when commit is not part of deployment' do + expect(project.environments_for('master', project.commit('feature'))).to be_empty + end + end + + context 'branch deployment' do + before do + create(:deployment, environment: environment, ref: 'master', sha: project.commit.id) + end + + it 'returns environment when ref is set' do + expect(project.environments_for('master', project.commit)).to contain_exactly(environment) + end + + it 'does not environment when ref is different' do + expect(project.environments_for('feature', project.commit)).to be_empty + end + + it 'does not return environment when commit is not part of deployment' do + expect(project.environments_for('master', project.commit('feature'))).to be_empty + end + end + end + + def enable_lfs + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end end diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 5eaf0d3b7a6..e0f2dadf189 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -73,9 +73,71 @@ describe ProjectTeam, models: true do end end - describe '#find_member' do + describe '#fetch_members' do context 'personal project' do let(:project) { create(:empty_project) } + + it 'returns project members' do + user = create(:user) + project.team << [user, :guest] + + expect(project.team.members).to contain_exactly(user) + end + + it 'returns project members of a specified level' do + user = create(:user) + project.team << [user, :reporter] + + expect(project.team.guests).to be_empty + expect(project.team.reporters).to contain_exactly(user) + end + + it 'returns invited members of a group' do + group_member = create(:group_member) + + project.project_group_links.create!( + group: group_member.group, + group_access: Gitlab::Access::GUEST + ) + + expect(project.team.members).to contain_exactly(group_member.user) + end + + it 'returns invited members of a group of a specified level' do + group_member = create(:group_member) + + project.project_group_links.create!( + group: group_member.group, + group_access: Gitlab::Access::REPORTER + ) + + expect(project.team.guests).to be_empty + expect(project.team.reporters).to contain_exactly(group_member.user) + end + end + + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + + it 'returns project members' do + group_member = create(:group_member, group: group) + + expect(project.team.members).to contain_exactly(group_member.user) + end + + it 'returns project members of a specified level' do + group_member = create(:group_member, :reporter, group: group) + + expect(project.team.guests).to be_empty + expect(project.team.reporters).to contain_exactly(group_member.user) + end + end + end + + describe '#find_member' do + context 'personal project' do + let(:project) { create(:empty_project, :public) } let(:requester) { create(:user) } before do @@ -138,7 +200,7 @@ describe ProjectTeam, models: true do let(:requester) { create(:user) } context 'personal project' do - let(:project) { create(:empty_project) } + let(:project) { create(:empty_project, :public) } context 'when project is not shared with group' do before do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 2a053b1804f..f977cf73673 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -7,16 +7,34 @@ describe Repository, models: true do let(:project) { create(:project) } let(:repository) { project.repository } let(:user) { create(:user) } + let(:commit_options) do author = repository.user_to_committer(user) { message: 'Test message', committer: author, author: author } end + let(:merge_commit) do merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) merge_commit_id = repository.merge(user, merge_request, commit_options) repository.commit(merge_commit_id) end + let(:author_email) { FFaker::Internet.email } + + # I have to remove periods from the end of the name + # This happened when the user's name had a suffix (i.e. "Sr.") + # This seems to be what git does under the hood. For example, this commit: + # + # $ git commit --author='Foo Sr. <foo@example.com>' -m 'Where's my trailing period?' + # + # results in this: + # + # $ git show --pretty + # ... + # Author: Foo Sr <foo@example.com> + # ... + let(:author_name) { FFaker::Name.name.chomp("\.") } + describe '#branch_names_contains' do subject { repository.branch_names_contains(sample_commit.id) } @@ -75,6 +93,26 @@ describe Repository, models: true do end end + describe '#ref_name_for_sha' do + context 'ref found' do + it 'returns the ref' do + allow_any_instance_of(Gitlab::Popen).to receive(:popen). + and_return(["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]) + + expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77' + end + end + + context 'ref not found' do + it 'returns nil' do + allow_any_instance_of(Gitlab::Popen).to receive(:popen). + and_return(["", 0]) + + expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq nil + end + end + end + describe '#last_commit_for_path' do subject { repository.last_commit_for_path(sample_commit.id, '.gitignore').id } @@ -82,12 +120,20 @@ describe Repository, models: true do end describe '#find_commits_by_message' do - subject { repository.find_commits_by_message('submodule').map{ |k| k.id } } + it 'returns commits with messages containing a given string' do + commit_ids = repository.find_commits_by_message('submodule').map(&:id) + + expect(commit_ids).to include('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + expect(commit_ids).to include('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') + expect(commit_ids).to include('cfe32cf61b73a0d5e9f13e774abde7ff789b1660') + expect(commit_ids).not_to include('913c66a37b4a45b9769037c55c2d238bd0942d2e') + end + + it 'is case insensitive' do + commit_ids = repository.find_commits_by_message('SUBMODULE').map(&:id) - it { is_expected.to include('5937ac0a7beb003549fc5fd26fc247adbce4a52e') } - it { is_expected.to include('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } - it { is_expected.to include('cfe32cf61b73a0d5e9f13e774abde7ff789b1660') } - it { is_expected.not_to include('913c66a37b4a45b9769037c55c2d238bd0942d2e') } + expect(commit_ids).to include('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + end end describe '#blob_at' do @@ -99,11 +145,30 @@ describe Repository, models: true do end describe '#merged_to_root_ref?' do - context 'merged branch' do + context 'merged branch without ff' do + subject { repository.merged_to_root_ref?('branch-merged') } + + it { is_expected.to be_truthy } + end + + # If the HEAD was ff then it will be false + context 'merged with ff' do subject { repository.merged_to_root_ref?('improve/awesome') } it { is_expected.to be_truthy } end + + context 'not merged branch' do + subject { repository.merged_to_root_ref?('not-merged-branch') } + + it { is_expected.to be_falsey } + end + + context 'default branch' do + subject { repository.merged_to_root_ref?('master') } + + it { is_expected.to be_falsey } + end end describe '#can_be_merged?' do @@ -132,7 +197,31 @@ describe Repository, models: true do end end - describe :commit_file do + describe "#commit_dir" do + it "commits a change that creates a new directory" do + expect do + repository.commit_dir(user, 'newdir', 'Create newdir', 'master') + end.to change { repository.commits('master').count }.by(1) + + newdir = repository.tree('master', 'newdir') + expect(newdir.path).to eq('newdir') + end + + context "when an author is specified" do + it "uses the given email/name to set the commit's author" do + expect do + repository.commit_dir(user, "newdir", "Add newdir", 'master', author_email: author_email, author_name: author_name) + end.to change { repository.commits('master').count }.by(1) + + last_commit = repository.commit + + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end + end + + describe "#commit_file" do it 'commits change to a file successfully' do expect do repository.commit_file(user, 'CHANGELOG', 'Changelog!', @@ -144,9 +233,23 @@ describe Repository, models: true do expect(blob.data).to eq('Changelog!') end + + context "when an author is specified" do + it "uses the given email/name to set the commit's author" do + expect do + repository.commit_file(user, "README", 'README!', 'Add README', + 'master', true, author_email: author_email, author_name: author_name) + end.to change { repository.commits('master').count }.by(1) + + last_commit = repository.commit + + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end end - describe :update_file do + describe "#update_file" do it 'updates filename successfully' do expect do repository.update_file(user, 'NEWLICENSE', 'Copyright!', @@ -160,6 +263,85 @@ describe Repository, models: true do expect(files).not_to include('LICENSE') expect(files).to include('NEWLICENSE') end + + context "when an author is specified" do + it "uses the given email/name to set the commit's author" do + repository.commit_file(user, "README", 'README!', 'Add README', 'master', true) + + expect do + repository.update_file(user, 'README', "Updated README!", + branch: 'master', + previous_path: 'README', + message: 'Update README', + author_email: author_email, + author_name: author_name) + end.to change { repository.commits('master').count }.by(1) + + last_commit = repository.commit + + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end + end + + describe "#remove_file" do + it 'removes file successfully' do + repository.commit_file(user, "README", 'README!', 'Add README', 'master', true) + + expect do + repository.remove_file(user, "README", "Remove README", 'master') + end.to change { repository.commits('master').count }.by(1) + + expect(repository.blob_at('master', 'README')).to be_nil + end + + context "when an author is specified" do + it "uses the given email/name to set the commit's author" do + repository.commit_file(user, "README", 'README!', 'Add README', 'master', true) + + expect do + repository.remove_file(user, "README", "Remove README", 'master', author_email: author_email, author_name: author_name) + end.to change { repository.commits('master').count }.by(1) + + last_commit = repository.commit + + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end + end + + describe '#get_committer_and_author' do + it 'returns the committer and author data' do + options = repository.get_committer_and_author(user) + expect(options[:committer][:email]).to eq(user.email) + expect(options[:author][:email]).to eq(user.email) + end + + context 'when the email/name are given' do + it 'returns an object containing the email/name' do + options = repository.get_committer_and_author(user, email: author_email, name: author_name) + expect(options[:author][:email]).to eq(author_email) + expect(options[:author][:name]).to eq(author_name) + end + end + + context 'when the email is given but the name is not' do + it 'returns the committer as the author' do + options = repository.get_committer_and_author(user, email: author_email) + expect(options[:author][:email]).to eq(user.email) + expect(options[:author][:name]).to eq(user.name) + end + end + + context 'when the name is given but the email is not' do + it 'returns nil' do + options = repository.get_committer_and_author(user, name: author_name) + expect(options[:author][:email]).to eq(user.email) + expect(options[:author][:name]).to eq(user.name) + end + end end describe "search_files" do @@ -184,33 +366,17 @@ describe Repository, models: true do subject { results.first } it { is_expected.to be_an String } - it { expect(subject.lines[2]).to eq("master:CHANGELOG:188: - Feature: Replace teams with group membership\n") } + it { expect(subject.lines[2]).to eq("master:CHANGELOG:190: - Feature: Replace teams with group membership\n") } end + end - describe 'parsing result' do - subject { repository.parse_search_result(search_result) } - let(:search_result) { results.first } - - it { is_expected.to be_an OpenStruct } - it { expect(subject.filename).to eq('CHANGELOG') } - it { expect(subject.basename).to eq('CHANGELOG') } - it { expect(subject.ref).to eq('master') } - it { expect(subject.startline).to eq(186) } - it { expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") } - - context "when filename has extension" do - let(:search_result) { "master:CONTRIBUTE.md:5:- [Contribute to GitLab](#contribute-to-gitlab)\n" } - - it { expect(subject.filename).to eq('CONTRIBUTE.md') } - it { expect(subject.basename).to eq('CONTRIBUTE') } - end + describe '#create_ref' do + it 'redirects the call to fetch_ref' do + ref, ref_path = '1', '2' - context "when file under directory" do - let(:search_result) { "master:a/b/c.md:5:a b c\n" } + expect(repository).to receive(:fetch_ref).with(repository.path_to_repo, ref, ref_path) - it { expect(subject.filename).to eq('a/b/c.md') } - it { expect(subject.basename).to eq('a/b/c') } - end + repository.create_ref(ref, ref_path) end end @@ -340,14 +506,14 @@ describe Repository, models: true do describe '#add_branch' do context 'when pre hooks were successful' do - it 'should run without errors' do + it 'runs without errors' do hook = double(trigger: [true, nil]) expect(Gitlab::Git::Hook).to receive(:new).exactly(3).times.and_return(hook) expect { repository.add_branch(user, 'new_feature', 'master') }.not_to raise_error end - it 'should create the branch' do + it 'creates the branch' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil]) branch = repository.add_branch(user, 'new_feature', 'master') @@ -363,7 +529,7 @@ describe Repository, models: true do end context 'when pre hooks failed' do - it 'should get an error' do + it 'gets an error' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) expect do @@ -371,7 +537,7 @@ describe Repository, models: true do end.to raise_error(GitHooksService::PreReceiveError) end - it 'should not create the branch' do + it 'does not create the branch' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) expect do @@ -382,19 +548,37 @@ describe Repository, models: true do end end + describe '#find_branch' do + it 'loads a branch with a fresh repo' do + expect(Gitlab::Git::Repository).to receive(:new).twice.and_call_original + + 2.times do + expect(repository.find_branch('feature')).not_to be_nil + end + end + + it 'loads a branch with a cached repo' do + expect(Gitlab::Git::Repository).to receive(:new).once.and_call_original + + 2.times do + expect(repository.find_branch('feature', fresh_repo: false)).not_to be_nil + end + end + end + describe '#rm_branch' do let(:old_rev) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } # git rev-parse feature let(:blank_sha) { '0000000000000000000000000000000000000000' } context 'when pre hooks were successful' do - it 'should run without errors' do + it 'runs without errors' do expect_any_instance_of(GitHooksService).to receive(:execute). with(user, project.repository.path_to_repo, old_rev, blank_sha, 'refs/heads/feature') expect { repository.rm_branch(user, 'feature') }.not_to raise_error end - it 'should delete the branch' do + it 'deletes the branch' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil]) expect { repository.rm_branch(user, 'feature') }.not_to raise_error @@ -404,7 +588,7 @@ describe Repository, models: true do end context 'when pre hooks failed' do - it 'should get an error' do + it 'gets an error' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) expect do @@ -412,7 +596,7 @@ describe Repository, models: true do end.to raise_error(GitHooksService::PreReceiveError) end - it 'should not delete the branch' do + it 'does not delete the branch' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) expect do @@ -423,43 +607,77 @@ describe Repository, models: true do end end - describe '#commit_with_hooks' do + describe '#update_branch_with_hooks' do let(:old_rev) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } # git rev-parse feature + let(:new_rev) { 'a74ae73c1ccde9b974a70e82b901588071dc142a' } # commit whose parent is old_rev context 'when pre hooks were successful' do before do expect_any_instance_of(GitHooksService).to receive(:execute). - with(user, repository.path_to_repo, old_rev, sample_commit.id, 'refs/heads/feature'). + with(user, repository.path_to_repo, old_rev, new_rev, 'refs/heads/feature'). and_yield.and_return(true) end - it 'should run without errors' do + it 'runs without errors' do expect do - repository.commit_with_hooks(user, 'feature') { sample_commit.id } + repository.update_branch_with_hooks(user, 'feature') { new_rev } end.not_to raise_error end - it 'should ensure the autocrlf Git option is set to :input' do + it 'ensures the autocrlf Git option is set to :input' do expect(repository).to receive(:update_autocrlf_option) - repository.commit_with_hooks(user, 'feature') { sample_commit.id } + repository.update_branch_with_hooks(user, 'feature') { new_rev } end context "when the branch wasn't empty" do it 'updates the head' do expect(repository.find_branch('feature').target.id).to eq(old_rev) - repository.commit_with_hooks(user, 'feature') { sample_commit.id } - expect(repository.find_branch('feature').target.id).to eq(sample_commit.id) + repository.update_branch_with_hooks(user, 'feature') { new_rev } + expect(repository.find_branch('feature').target.id).to eq(new_rev) end end end + context 'when the update adds more than one commit' do + it 'runs without errors' do + old_rev = '33f3729a45c02fc67d00adb1b8bca394b0e761d9' + + # old_rev is an ancestor of new_rev + expect(repository.rugged.merge_base(old_rev, new_rev)).to eq(old_rev) + + # old_rev is not a direct ancestor (parent) of new_rev + expect(repository.rugged.lookup(new_rev).parent_ids).not_to include(old_rev) + + branch = 'feature-ff-target' + repository.add_branch(user, branch, old_rev) + + expect { repository.update_branch_with_hooks(user, branch) { new_rev } }.not_to raise_error + end + end + + context 'when the update would remove commits from the target branch' do + it 'raises an exception' do + branch = 'master' + old_rev = repository.find_branch(branch).target.sha + + # The 'master' branch is NOT an ancestor of new_rev. + expect(repository.rugged.merge_base(old_rev, new_rev)).not_to eq(old_rev) + + # Updating 'master' to new_rev would lose the commits on 'master' that + # are not contained in new_rev. This should not be allowed. + expect do + repository.update_branch_with_hooks(user, branch) { new_rev } + end.to raise_error(Repository::CommitError) + end + end + context 'when pre hooks failed' do - it 'should get an error' do + it 'gets an error' do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) expect do - repository.commit_with_hooks(user, 'feature') { sample_commit.id } + repository.update_branch_with_hooks(user, 'feature') { new_rev } end.to raise_error(GitHooksService::PreReceiveError) end end @@ -467,6 +685,7 @@ describe Repository, models: true do context 'when target branch is different from source branch' do before do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, '']) + allow(repository).to receive(:update_ref!) end it 'expires branch cache' do @@ -477,7 +696,7 @@ describe Repository, models: true do expect(repository).to receive(:expire_has_visible_content_cache) expect(repository).to receive(:expire_branch_count_cache) - repository.commit_with_hooks(user, 'new-feature') { sample_commit.id } + repository.update_branch_with_hooks(user, 'new-feature') { new_rev } end end @@ -715,10 +934,18 @@ describe Repository, models: true do end describe '#merge' do - it 'should merge the code and return the commit id' do + it 'merges the code and return the commit id' do expect(merge_commit).to be_present expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present end + + it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do + merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) + merge_commit_id = repository.merge(user, merge_request, commit_options) + repository.commit(merge_commit_id) + + expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id) + end end describe '#revert' do @@ -726,13 +953,13 @@ describe Repository, models: true do let(:update_image_commit) { repository.commit('2f63565e7aac07bcdadb654e253078b727143ec4') } context 'when there is a conflict' do - it 'should abort the operation' do + it 'aborts the operation' do expect(repository.revert(user, new_image_commit, 'master')).to eq(false) end end context 'when commit was already reverted' do - it 'should abort the operation' do + it 'aborts the operation' do repository.revert(user, update_image_commit, 'master') expect(repository.revert(user, update_image_commit, 'master')).to eq(false) @@ -740,13 +967,13 @@ describe Repository, models: true do end context 'when commit can be reverted' do - it 'should revert the changes' do + it 'reverts the changes' do expect(repository.revert(user, update_image_commit, 'master')).to be_truthy end end context 'reverting a merge commit' do - it 'should revert the changes' do + it 'reverts the changes' do merge_commit expect(repository.blob_at_branch('master', 'files/ruby/feature.rb')).to be_present @@ -762,13 +989,13 @@ describe Repository, models: true do let(:pickable_merge) { repository.commit('e56497bb5f03a90a51293fc6d516788730953899') } context 'when there is a conflict' do - it 'should abort the operation' do + it 'aborts the operation' do expect(repository.cherry_pick(user, conflict_commit, 'master')).to eq(false) end end context 'when commit was already cherry-picked' do - it 'should abort the operation' do + it 'aborts the operation' do repository.cherry_pick(user, pickable_commit, 'master') expect(repository.cherry_pick(user, pickable_commit, 'master')).to eq(false) @@ -776,17 +1003,17 @@ describe Repository, models: true do end context 'when commit can be cherry-picked' do - it 'should cherry-pick the changes' do + it 'cherry-picks the changes' do expect(repository.cherry_pick(user, pickable_commit, 'master')).to be_truthy end end context 'cherry-picking a merge commit' do - it 'should cherry-pick the changes' do - expect(repository.blob_at_branch('master', 'foo/bar/.gitkeep')).to be_nil + it 'cherry-picks the changes' do + expect(repository.blob_at_branch('improve/awesome', 'foo/bar/.gitkeep')).to be_nil - repository.cherry_pick(user, pickable_merge, 'master') - expect(repository.blob_at_branch('master', 'foo/bar/.gitkeep')).not_to be_nil + repository.cherry_pick(user, pickable_merge, 'improve/awesome') + expect(repository.blob_at_branch('improve/awesome', 'foo/bar/.gitkeep')).not_to be_nil end end end @@ -1242,4 +1469,18 @@ describe Repository, models: true do File.delete(path) end end + + describe '#update_ref!' do + it 'can create a ref' do + repository.update_ref!('refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA) + + expect(repository.find_branch('foobar')).not_to be_nil + end + + it 'raises CommitError when the ref update fails' do + expect do + repository.update_ref!('refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA) + end.to raise_error(Repository::CommitError) + end + end end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 67b3783d514..43937a54b2c 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -65,13 +65,13 @@ describe Service, models: true do end let(:project) { create(:project) } - describe 'should be prefilled for projects pushover service' do + describe 'is prefilled for projects pushover service' do before do service_template project.build_missing_services end - it "should have all fields prefilled" do + it "has all fields prefilled" do service = project.pushover_service expect(service.template).to eq(false) expect(service.device).to eq('MyDevice') @@ -203,6 +203,23 @@ describe Service, models: true do end end + describe 'initialize service with no properties' do + let(:service) do + GitlabIssueTrackerService.create( + project: create(:project), + title: 'random title' + ) + end + + it 'does not raise error' do + expect { service }.not_to raise_error + end + + it 'creates the properties' do + expect(service.properties).to eq({ "title" => "random title" }) + end + end + describe "callbacks" do let(:project) { create(:project) } let!(:service) do @@ -221,7 +238,7 @@ describe Service, models: true do it "updates the has_external_issue_tracker boolean" do expect do service.save! - end.to change { service.project.has_external_issue_tracker }.from(nil).to(true) + end.to change { service.project.has_external_issue_tracker }.from(false).to(true) end end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 0621c6a06ce..f62f6bacbaa 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -9,12 +9,14 @@ describe Snippet, models: true do it { is_expected.to include_module(Participable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } + it { is_expected.to include_module(Awardable) } end describe 'associations' do it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to belong_to(:project) } it { is_expected.to have_many(:notes).dependent(:destroy) } + it { is_expected.to have_many(:award_emoji).dependent(:destroy) } end describe 'validation' do @@ -44,6 +46,13 @@ describe Snippet, models: true do end end + describe "#content_html_invalidated?" do + let(:snippet) { create(:snippet, content: "md", content_html: "html", file_name: "foo.md") } + it "invalidates the HTML cache of content when the filename changes" do + expect { snippet.file_name = "foo.rb" }.to change { snippet.content_html_invalidated? }.from(false).to(true) + end + end + describe '.search' do let(:snippet) { create(:snippet) } diff --git a/spec/models/trending_project_spec.rb b/spec/models/trending_project_spec.rb new file mode 100644 index 00000000000..cc28c6d4004 --- /dev/null +++ b/spec/models/trending_project_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe TrendingProject do + let(:user) { create(:user) } + let(:public_project1) { create(:empty_project, :public) } + let(:public_project2) { create(:empty_project, :public) } + let(:public_project3) { create(:empty_project, :public) } + let(:private_project) { create(:empty_project, :private) } + let(:internal_project) { create(:empty_project, :internal) } + + before do + 3.times do + create(:note_on_commit, project: public_project1) + end + + 2.times do + create(:note_on_commit, project: public_project2) + end + + create(:note_on_commit, project: public_project3, created_at: 5.weeks.ago) + create(:note_on_commit, project: private_project) + create(:note_on_commit, project: internal_project) + end + + describe '.refresh!' do + before do + described_class.refresh! + end + + it 'populates the trending projects table' do + expect(described_class.count).to eq(2) + end + + it 'removes existing rows before populating the table' do + described_class.refresh! + + expect(described_class.count).to eq(2) + end + + it 'stores the project IDs for every trending project' do + rows = described_class.order(id: :asc).all + + expect(rows[0].project_id).to eq(public_project1.id) + expect(rows[1].project_id).to eq(public_project2.id) + end + + it 'does not store projects that fall out of the trending time range' do + expect(described_class.where(project_id: public_project3).any?).to eq(false) + end + + it 'stores only public projects' do + expect(described_class.where(project_id: [public_project1.id, public_project2.id]).count).to eq(2) + expect(described_class.where(project_id: [private_project.id, internal_project.id]).count).to eq(0) + end + end +end diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb new file mode 100644 index 00000000000..a8c25766e73 --- /dev/null +++ b/spec/models/user_agent_detail_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe UserAgentDetail, type: :model do + describe '.submittable?' do + it 'is submittable when not already submitted' do + detail = build(:user_agent_detail) + + expect(detail.submittable?).to be_truthy + end + + it 'is not submittable when already submitted' do + detail = build(:user_agent_detail, submitted: true) + + expect(detail.submittable?).to be_falsey + end + end + + describe '.valid?' do + it 'is valid with a subject' do + detail = build(:user_agent_detail) + + expect(detail).to be_valid + end + + it 'is invalid without a subject' do + detail = build(:user_agent_detail, subject: nil) + + expect(detail).not_to be_valid + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9f432501c59..65b2896930a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -166,7 +166,7 @@ describe User, models: true do allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return(['*.example.com']) end - it 'should give priority to whitelist and allow info@test.example.com' do + it 'gives priority to whitelist and allow info@test.example.com' do user = build(:user, email: 'info@test.example.com') expect(user).to be_valid end @@ -304,18 +304,18 @@ describe User, models: true do end describe '#generate_password' do - it "should execute callback when force_random_password specified" do + it "executes callback when force_random_password specified" do user = build(:user, force_random_password: true) expect(user).to receive(:generate_password) user.save end - it "should not generate password by default" do + it "does not generate password by default" do user = create(:user, password: 'abcdefghe') expect(user.password).to eq('abcdefghe') end - it "should generate password when forcing random password" do + it "generates password when forcing random password" do allow(Devise).to receive(:friendly_token).and_return('123456789') user = create(:user, password: 'abcdefg', force_random_password: true) expect(user.password).to eq('12345678') @@ -323,7 +323,7 @@ describe User, models: true do end describe 'authentication token' do - it "should have authentication token" do + it "has authentication token" do user = create(:user) expect(user.authentication_token).not_to be_blank end @@ -430,7 +430,7 @@ describe User, models: true do describe 'blocking user' do let(:user) { create(:user, name: 'John Smith') } - it "should block user" do + it "blocks user" do user.block expect(user.blocked?).to be_truthy end @@ -501,7 +501,7 @@ describe User, models: true do describe 'with defaults' do let(:user) { User.new } - it "should apply defaults to user" do + it "applies defaults to user" do expect(user.projects_limit).to eq(Gitlab.config.gitlab.default_projects_limit) expect(user.can_create_group).to eq(Gitlab.config.gitlab.default_can_create_group) expect(user.theme_id).to eq(Gitlab.config.gitlab.default_theme) @@ -512,7 +512,7 @@ describe User, models: true do describe 'with default overrides' do let(:user) { User.new(projects_limit: 123, can_create_group: false, can_create_team: true, theme_id: 1) } - it "should apply defaults to user" do + it "applies defaults to user" do expect(user.projects_limit).to eq(123) expect(user.can_create_group).to be_falsey expect(user.theme_id).to eq(1) @@ -602,7 +602,7 @@ describe User, models: true do describe 'by_username_or_id' do let(:user1) { create(:user, username: 'foo') } - it "should get the correct user" do + it "gets the correct user" do expect(User.by_username_or_id(user1.id)).to eq(user1) expect(User.by_username_or_id('foo')).to eq(user1) expect(User.by_username_or_id(-1)).to be_nil @@ -610,11 +610,28 @@ describe User, models: true do end end + describe '.find_by_ssh_key_id' do + context 'using an existing SSH key ID' do + let(:user) { create(:user) } + let(:key) { create(:key, user: user) } + + it 'returns the corresponding User' do + expect(described_class.find_by_ssh_key_id(key.id)).to eq(user) + end + end + + context 'using an invalid SSH key ID' do + it 'returns nil' do + expect(described_class.find_by_ssh_key_id(-1)).to be_nil + end + end + end + describe '.by_login' do let(:username) { 'John' } let!(:user) { create(:user, username: username) } - it 'should get the correct user' do + it 'gets the correct user' do expect(User.by_login(user.email.upcase)).to eq user expect(User.by_login(user.email)).to eq user expect(User.by_login(username.downcase)).to eq user @@ -639,7 +656,7 @@ describe User, models: true do describe 'all_ssh_keys' do it { is_expected.to have_many(:keys).dependent(:destroy) } - it "should have all ssh keys" do + it "has all ssh keys" do user = create :user key = create :key, key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD33bWLBxu48Sev9Fert1yzEO4WGcWglWF7K/AwblIUFselOt/QdOL9DSjpQGxLagO1s9wl53STIO8qGS4Ms0EJZyIXOEFMjFJ5xmjSy+S37By4sG7SsltQEHMxtbtFOaW5LV2wCrX+rUsRNqLMamZjgjcPO0/EgGCXIGMAYW4O7cwGZdXWYIhQ1Vwy+CsVMDdPkPgBXqK7nR/ey8KMs8ho5fMNgB5hBw/AL9fNGhRw3QTD6Q12Nkhl4VZES2EsZqlpNnJttnPdp847DUsT6yuLRlfiQfz5Cn9ysHFdXObMN5VYIiPFwHeYCZp1X2S4fDZooRE8uOLTfxWHPXwrhqSH", user_id: user.id @@ -650,12 +667,12 @@ describe User, models: true do describe '#avatar_type' do let(:user) { create(:user) } - it "should be true if avatar is image" do + it "is true if avatar is image" do user.update_attribute(:avatar, 'uploads/avatar.png') expect(user.avatar_type).to be_truthy end - it "should be false if avatar is html page" do + it "is false if avatar is html page" do user.update_attribute(:avatar, 'uploads/avatar.html') expect(user.avatar_type).to eq(["only images allowed"]) end @@ -895,7 +912,9 @@ describe User, models: true do subject { create(:user) } let!(:project1) { create(:project) } let!(:project2) { create(:project, forked_from_project: project1) } - let!(:push_data) { Gitlab::PushDataBuilder.build_sample(project2, subject) } + let!(:push_data) do + Gitlab::DataBuilder::Push.build_sample(project2, subject) + end let!(:push_event) { create(:event, action: Event::PUSHED, project: project2, target: project1, author: subject, data: push_data) } before do @@ -918,6 +937,16 @@ describe User, models: true do expect(subject.recent_push).to eq(nil) end + + it "includes push events on any of the provided projects" do + expect(subject.recent_push(project1)).to eq(nil) + expect(subject.recent_push(project2)).to eq(push_event) + + push_data1 = Gitlab::DataBuilder::Push.build_sample(project1, subject) + push_event1 = create(:event, action: Event::PUSHED, project: project1, target: project1, author: subject, data: push_data1) + + expect(subject.recent_push([project1, project2])).to eq(push_event1) # Newest + end end describe '#authorized_groups' do @@ -955,6 +984,52 @@ describe User, models: true do end end + describe '#projects_where_can_admin_issues' do + let(:user) { create(:user) } + + it 'includes projects for which the user access level is above or equal to reporter' do + create(:project) + reporter_project = create(:project) + developer_project = create(:project) + master_project = create(:project) + + reporter_project.team << [user, :reporter] + developer_project.team << [user, :developer] + master_project.team << [user, :master] + + expect(user.projects_where_can_admin_issues.to_a).to eq([master_project, developer_project, reporter_project]) + expect(user.can?(:admin_issue, master_project)).to eq(true) + expect(user.can?(:admin_issue, developer_project)).to eq(true) + expect(user.can?(:admin_issue, reporter_project)).to eq(true) + end + + it 'does not include for which the user access level is below reporter' do + project = create(:project) + guest_project = create(:project) + + guest_project.team << [user, :guest] + + expect(user.projects_where_can_admin_issues.to_a).to be_empty + expect(user.can?(:admin_issue, guest_project)).to eq(false) + expect(user.can?(:admin_issue, project)).to eq(false) + end + + it 'does not include archived projects' do + project = create(:project) + project.update_attributes(archived: true) + + expect(user.projects_where_can_admin_issues.to_a).to be_empty + expect(user.can?(:admin_issue, project)).to eq(false) + end + + it 'does not include projects for which issues are disabled' do + project = create(:project, issues_access_level: ProjectFeature::DISABLED) + + expect(user.projects_where_can_admin_issues.to_a).to be_empty + expect(user.can?(:admin_issue, project)).to eq(false) + end + end + describe '#ci_authorized_runners' do let(:user) { create(:user) } let(:runner) { create(:ci_runner) } diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index ddc49495eda..5c34b1b0a30 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -147,12 +147,12 @@ describe WikiPage, models: true do @page = wiki.find_page("Delete Page") end - it "should delete the page" do + it "deletes the page" do @page.delete expect(wiki.pages).to be_empty end - it "should return true" do + it "returns true" do expect(@page.delete).to eq(true) end end @@ -183,7 +183,7 @@ describe WikiPage, models: true do destroy_page("Title") end - it "should be replace a hyphen to a space" do + it "replaces a hyphen to a space" do @page.title = "Import-existing-repositories-into-GitLab" expect(@page.title).to eq("Import existing repositories into GitLab") end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb new file mode 100644 index 00000000000..658e3c13a73 --- /dev/null +++ b/spec/policies/project_policy_spec.rb @@ -0,0 +1,165 @@ +require 'spec_helper' + +describe ProjectPolicy, models: true do + let(:guest) { create(:user) } + let(:reporter) { create(:user) } + let(:dev) { create(:user) } + let(:master) { create(:user) } + let(:owner) { create(:user) } + let(:project) { create(:empty_project, :public, namespace: owner.namespace) } + + let(:guest_permissions) do + [ + :read_project, :read_board, :read_list, :read_wiki, :read_issue, :read_label, + :read_milestone, :read_project_snippet, :read_project_member, + :read_note, :create_project, :create_issue, :create_note, + :upload_file + ] + end + + let(:reporter_permissions) do + [ + :download_code, :fork_project, :create_project_snippet, :update_issue, + :admin_issue, :admin_label, :admin_list, :read_commit_status, :read_build, + :read_container_image, :read_pipeline, :read_environment, :read_deployment, + :read_merge_request + ] + end + + let(:team_member_reporter_permissions) do + [ + :build_download_code, :build_read_container_image + ] + end + + let(:developer_permissions) do + [ + :admin_merge_request, :update_merge_request, :create_commit_status, + :update_commit_status, :create_build, :update_build, :create_pipeline, + :update_pipeline, :create_merge_request, :create_wiki, :push_code, + :resolve_note, :create_container_image, :update_container_image, + :create_environment, :create_deployment + ] + end + + let(:master_permissions) do + [ + :push_code_to_protected_branches, :update_project_snippet, :update_environment, + :update_deployment, :admin_milestone, :admin_project_snippet, + :admin_project_member, :admin_note, :admin_wiki, :admin_project, + :admin_commit_status, :admin_build, :admin_container_image, + :admin_pipeline, :admin_environment, :admin_deployment + ] + end + + let(:public_permissions) do + [ + :download_code, :fork_project, :read_commit_status, :read_pipeline, + :read_container_image, :build_download_code, :build_read_container_image + ] + end + + let(:owner_permissions) do + [ + :change_namespace, :change_visibility_level, :rename_project, :remove_project, + :archive_project, :remove_fork_project, :destroy_merge_request, :destroy_issue + ] + end + + before do + project.team << [guest, :guest] + project.team << [master, :master] + project.team << [dev, :developer] + project.team << [reporter, :reporter] + end + + it 'does not include the read_issue permission when the issue author is not a member of the private project' do + project = create(:project, :private) + issue = create(:issue, project: project) + user = issue.author + + expect(project.team.member?(issue.author)).to eq(false) + + expect(BasePolicy.class_for(project).abilities(user, project).can_set). + not_to include(:read_issue) + + expect(Ability.allowed?(user, :read_issue, project)).to be_falsy + end + + context 'abilities for non-public projects' do + let(:project) { create(:empty_project, namespace: owner.namespace) } + + subject { described_class.abilities(current_user, project).to_set } + + context 'with no user' do + let(:current_user) { nil } + + it { is_expected.to be_empty } + end + + context 'guests' do + let(:current_user) { guest } + + it do + is_expected.to include(*guest_permissions) + is_expected.not_to include(*reporter_permissions) + is_expected.not_to include(*team_member_reporter_permissions) + is_expected.not_to include(*developer_permissions) + is_expected.not_to include(*master_permissions) + is_expected.not_to include(*owner_permissions) + end + end + + context 'reporter' do + let(:current_user) { reporter } + + it do + is_expected.to include(*guest_permissions) + is_expected.to include(*reporter_permissions) + is_expected.to include(*team_member_reporter_permissions) + is_expected.not_to include(*developer_permissions) + is_expected.not_to include(*master_permissions) + is_expected.not_to include(*owner_permissions) + end + end + + context 'developer' do + let(:current_user) { dev } + + it do + is_expected.to include(*guest_permissions) + is_expected.to include(*reporter_permissions) + is_expected.to include(*team_member_reporter_permissions) + is_expected.to include(*developer_permissions) + is_expected.not_to include(*master_permissions) + is_expected.not_to include(*owner_permissions) + end + end + + context 'master' do + let(:current_user) { master } + + it do + is_expected.to include(*guest_permissions) + is_expected.to include(*reporter_permissions) + is_expected.to include(*team_member_reporter_permissions) + is_expected.to include(*developer_permissions) + is_expected.to include(*master_permissions) + is_expected.not_to include(*owner_permissions) + end + end + + context 'owner' do + let(:current_user) { owner } + + it do + is_expected.to include(*guest_permissions) + is_expected.to include(*reporter_permissions) + is_expected.not_to include(*team_member_reporter_permissions) + is_expected.to include(*developer_permissions) + is_expected.to include(*master_permissions) + is_expected.to include(*owner_permissions) + end + end + end +end diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb new file mode 100644 index 00000000000..b467890a403 --- /dev/null +++ b/spec/requests/api/access_requests_spec.rb @@ -0,0 +1,270 @@ +require 'spec_helper' + +describe API::AccessRequests, api: true do + include ApiHelpers + + let(:master) { create(:user) } + let(:developer) { create(:user) } + let(:access_requester) { create(:user) } + let(:stranger) { create(:user) } + + let(:project) do + project = create(:project, :public, creator_id: master.id, namespace: master.namespace) + project.team << [developer, :developer] + project.team << [master, :master] + project.request_access(access_requester) + project + end + + let(:group) do + group = create(:group, :public) + group.add_developer(developer) + group.add_owner(master) + group.request_access(access_requester) + group + end + + shared_examples 'GET /:sources/:id/access_requests' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) } + end + + context 'when authenticated as a non-master/owner' do + %i[developer access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + get api("/#{source_type.pluralize}/#{source.id}/access_requests", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'returns access requesters' do + get api("/#{source_type.pluralize}/#{source.id}/access_requests", master) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + end + end + end + + shared_examples 'POST /:sources/:id/access_requests' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { post api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) } + end + + context 'when authenticated as a member' do + %i[developer master].each do |type| + context "as a #{type}" do + it 'returns 403' do + expect do + user = public_send(type) + post api("/#{source_type.pluralize}/#{source.id}/access_requests", user) + + expect(response).to have_http_status(403) + end.not_to change { source.requesters.count } + end + end + end + end + + context 'when authenticated as an access requester' do + it 'returns 400' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/access_requests", access_requester) + + expect(response).to have_http_status(400) + end.not_to change { source.requesters.count } + end + end + + context 'when authenticated as a stranger' do + context "when access request is disabled for the #{source_type}" do + before do + source.update(request_access_enabled: false) + end + + it 'returns 403' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) + + expect(response).to have_http_status(403) + end.not_to change { source.requesters.count } + end + end + + it 'returns 201' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/access_requests", stranger) + + expect(response).to have_http_status(201) + end.to change { source.requesters.count }.by(1) + + # User attributes + expect(json_response['id']).to eq(stranger.id) + expect(json_response['name']).to eq(stranger.name) + expect(json_response['username']).to eq(stranger.username) + expect(json_response['state']).to eq(stranger.state) + expect(json_response['avatar_url']).to eq(stranger.avatar_url) + expect(json_response['web_url']).to eq(Gitlab::Routing.url_helpers.user_url(stranger)) + + # Member attributes + expect(json_response['requested_at']).to be_present + end + end + end + end + + shared_examples 'PUT /:sources/:id/access_requests/:user_id/approve' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}/approve", stranger) } + end + + context 'when authenticated as a non-master/owner' do + %i[developer access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}/approve", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'returns 201' do + expect do + put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}/approve", master), + access_level: Member::MASTER + + expect(response).to have_http_status(201) + end.to change { source.members.count }.by(1) + # User attributes + expect(json_response['id']).to eq(access_requester.id) + expect(json_response['name']).to eq(access_requester.name) + expect(json_response['username']).to eq(access_requester.username) + expect(json_response['state']).to eq(access_requester.state) + expect(json_response['avatar_url']).to eq(access_requester.avatar_url) + expect(json_response['web_url']).to eq(Gitlab::Routing.url_helpers.user_url(access_requester)) + + # Member attributes + expect(json_response['access_level']).to eq(Member::MASTER) + end + + context 'user_id does not match an existing access requester' do + it 'returns 404' do + expect do + put api("/#{source_type.pluralize}/#{source.id}/access_requests/#{stranger.id}/approve", master) + + expect(response).to have_http_status(404) + end.not_to change { source.members.count } + end + end + end + end + end + + shared_examples 'DELETE /:sources/:id/access_requests/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", stranger) } + end + + context 'when authenticated as a non-master/owner' do + %i[developer stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as the access requester' do + it 'deletes the access requester' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", access_requester) + + expect(response).to have_http_status(200) + end.to change { source.requesters.count }.by(-1) + end + end + + context 'when authenticated as a master/owner' do + it 'deletes the access requester' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", master) + + expect(response).to have_http_status(200) + end.to change { source.requesters.count }.by(-1) + end + + context 'user_id matches a member, not an access requester' do + it 'returns 404' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{developer.id}", master) + + expect(response).to have_http_status(404) + end.not_to change { source.requesters.count } + end + end + + context 'user_id does not match an existing access requester' do + it 'returns 404' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{stranger.id}", master) + + expect(response).to have_http_status(404) + end.not_to change { source.requesters.count } + end + end + end + end + end + + it_behaves_like 'GET /:sources/:id/access_requests', 'project' do + let(:source) { project } + end + + it_behaves_like 'GET /:sources/:id/access_requests', 'group' do + let(:source) { group } + end + + it_behaves_like 'POST /:sources/:id/access_requests', 'project' do + let(:source) { project } + end + + it_behaves_like 'POST /:sources/:id/access_requests', 'group' do + let(:source) { group } + end + + it_behaves_like 'PUT /:sources/:id/access_requests/:user_id/approve', 'project' do + let(:source) { project } + end + + it_behaves_like 'PUT /:sources/:id/access_requests/:user_id/approve', 'group' do + let(:source) { group } + end + + it_behaves_like 'DELETE /:sources/:id/access_requests/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'DELETE /:sources/:id/access_requests/:user_id', 'group' do + let(:source) { group } + end +end diff --git a/spec/requests/api/api_helpers_spec.rb b/spec/requests/api/api_helpers_spec.rb index 831889afb6c..0f41f8dc7f1 100644 --- a/spec/requests/api/api_helpers_spec.rb +++ b/spec/requests/api/api_helpers_spec.rb @@ -3,13 +3,15 @@ require 'spec_helper' describe API::Helpers, api: true do include API::Helpers include ApiHelpers + include SentryHelper let(:user) { create(:user) } let(:admin) { create(:admin) } let(:key) { create(:key, user: user) } let(:params) { {} } - let(:env) { {} } + let(:env) { { 'REQUEST_METHOD' => 'GET' } } + let(:request) { Rack::Request.new(env) } def set_env(token_usr, identifier) clear_env @@ -35,25 +37,76 @@ describe API::Helpers, api: true do params.delete(API::Helpers::SUDO_PARAM) end + def warden_authenticate_returns(value) + warden = double("warden", authenticate: value) + env['warden'] = warden + end + + def doorkeeper_guard_returns(value) + allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ value } + end + def error!(message, status) raise Exception end describe ".current_user" do + subject { current_user } + + describe "Warden authentication" do + before { doorkeeper_guard_returns false } + + context "with invalid credentials" do + context "GET request" do + before { env['REQUEST_METHOD'] = 'GET' } + it { is_expected.to be_nil } + end + end + + context "with valid credentials" do + before { warden_authenticate_returns user } + + context "GET request" do + before { env['REQUEST_METHOD'] = 'GET' } + it { is_expected.to eq(user) } + end + + context "HEAD request" do + before { env['REQUEST_METHOD'] = 'HEAD' } + it { is_expected.to eq(user) } + end + + context "PUT request" do + before { env['REQUEST_METHOD'] = 'PUT' } + it { is_expected.to be_nil } + end + + context "POST request" do + before { env['REQUEST_METHOD'] = 'POST' } + it { is_expected.to be_nil } + end + + context "DELETE request" do + before { env['REQUEST_METHOD'] = 'DELETE' } + it { is_expected.to be_nil } + end + end + end + describe "when authenticating using a user's private token" do - it "should return nil for an invalid token" do + it "returns nil for an invalid token" do env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end - it "should return nil for a user without access" do + it "returns nil for a user without access" do env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end - it "should leave user as is when sudo not specified" do + it "leaves user as is when sudo not specified" do env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token expect(current_user).to eq(user) clear_env @@ -65,19 +118,19 @@ describe API::Helpers, api: true do describe "when authenticating using a user's personal access tokens" do let(:personal_access_token) { create(:personal_access_token, user: user) } - it "should return nil for an invalid token" do + it "returns nil for an invalid token" do env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end - it "should return nil for a user without access" do + it "returns nil for a user without access" do env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end - it "should leave user as is when sudo not specified" do + it "leaves user as is when sudo not specified" do env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) clear_env @@ -100,7 +153,7 @@ describe API::Helpers, api: true do end end - it "should change current user to sudo when admin" do + it "changes current user to sudo when admin" do set_env(admin, user.id) expect(current_user).to eq(user) set_param(admin, user.id) @@ -111,7 +164,7 @@ describe API::Helpers, api: true do expect(current_user).to eq(user) end - it "should throw an error when the current user is not an admin and attempting to sudo" do + it "throws an error when the current user is not an admin and attempting to sudo" do set_env(user, admin.id) expect { current_user }.to raise_error(Exception) set_param(user, admin.id) @@ -122,7 +175,7 @@ describe API::Helpers, api: true do expect { current_user }.to raise_error(Exception) end - it "should throw an error when the user cannot be found for a given id" do + it "throws an error when the user cannot be found for a given id" do id = user.id + admin.id expect(user.id).not_to eq(id) expect(admin.id).not_to eq(id) @@ -133,7 +186,7 @@ describe API::Helpers, api: true do expect { current_user }.to raise_error(Exception) end - it "should throw an error when the user cannot be found for a given username" do + it "throws an error when the user cannot be found for a given username" do username = "#{user.username}#{admin.username}" expect(user.username).not_to eq(username) expect(admin.username).not_to eq(username) @@ -144,7 +197,7 @@ describe API::Helpers, api: true do expect { current_user }.to raise_error(Exception) end - it "should handle sudo's to oneself" do + it "handles sudo's to oneself" do set_env(admin, admin.id) expect(current_user).to eq(admin) set_param(admin, admin.id) @@ -155,7 +208,7 @@ describe API::Helpers, api: true do expect(current_user).to eq(admin) end - it "should handle multiple sudo's to oneself" do + it "handles multiple sudo's to oneself" do set_env(admin, user.id) expect(current_user).to eq(user) expect(current_user).to eq(user) @@ -171,7 +224,7 @@ describe API::Helpers, api: true do expect(current_user).to eq(user) end - it "should handle multiple sudo's to oneself using string ids" do + it "handles multiple sudo's to oneself using string ids" do set_env(admin, user.id.to_s) expect(current_user).to eq(user) expect(current_user).to eq(user) @@ -183,7 +236,7 @@ describe API::Helpers, api: true do end describe '.sudo_identifier' do - it "should return integers when input is an int" do + it "returns integers when input is an int" do set_env(admin, '123') expect(sudo_identifier).to eq(123) set_env(admin, '0001234567890') @@ -195,7 +248,7 @@ describe API::Helpers, api: true do expect(sudo_identifier).to eq(1234567890) end - it "should return string when input is an is not an int" do + it "returns string when input is an is not an int" do set_env(admin, '12.30') expect(sudo_identifier).to eq("12.30") set_env(admin, 'hello') @@ -234,4 +287,30 @@ describe API::Helpers, api: true do expect(to_boolean(nil)).to be_nil end end + + describe '.handle_api_exception' do + before do + allow_any_instance_of(self.class).to receive(:sentry_enabled?).and_return(true) + allow_any_instance_of(self.class).to receive(:rack_response) + end + + it 'does not report a MethodNotAllowed exception to Sentry' do + exception = Grape::Exceptions::MethodNotAllowed.new({ 'X-GitLab-Test' => '1' }) + allow(exception).to receive(:backtrace).and_return(caller) + + expect(Raven).not_to receive(:capture_exception).with(exception) + + handle_api_exception(exception) + end + + it 'does report RuntimeError to Sentry' do + exception = RuntimeError.new('test error') + allow(exception).to receive(:backtrace).and_return(caller) + + expect_any_instance_of(self.class).to receive(:sentry_context) + expect(Raven).to receive(:capture_exception).with(exception) + + handle_api_exception(exception) + end + end end diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb index 2b74dd4bbb0..5ad4fc4865a 100644 --- a/spec/requests/api/award_emoji_spec.rb +++ b/spec/requests/api/award_emoji_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe API::API, api: true do include ApiHelpers let(:user) { create(:user) } - let!(:project) { create(:project) } - let(:issue) { create(:issue, project: project, author: user) } + let!(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } let!(:award_emoji) { create(:award_emoji, awardable: issue, user: user) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let!(:downvote) { create(:award_emoji, :downvote, awardable: merge_request, user: user) } @@ -22,7 +22,7 @@ describe API::API, api: true do expect(json_response.first['name']).to eq(award_emoji.name) end - it "should return a 404 error when issue id not found" do + it "returns a 404 error when issue id not found" do get api("/projects/#{project.id}/issues/12345/award_emoji", user) expect(response).to have_http_status(404) @@ -39,6 +39,19 @@ describe API::API, api: true do end end + context 'on a snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet) } + + it 'returns the awarded emoji' do + get api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(award.name) + end + end + context 'when the user has no access' do it 'returns a status code 404' do user1 = create(:user) @@ -91,6 +104,20 @@ describe API::API, api: true do end end + context 'on a snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet) } + + it 'returns the awarded emoji' do + get api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji/#{award.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(award.name) + expect(json_response['awardable_id']).to eq(snippet.id) + expect(json_response['awardable_type']).to eq("Snippet") + end + end + context 'when the user has no access' do it 'returns a status code 404' do user1 = create(:user) @@ -115,6 +142,8 @@ describe API::API, api: true do end describe "POST /projects/:id/awardable/:awardable_id/award_emoji" do + let(:issue2) { create(:issue, project: project, author: user) } + context "on an issue" do it "creates a new award emoji" do post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'blowfish' @@ -124,18 +153,24 @@ describe API::API, api: true do expect(json_response['user']['username']).to eq(user.username) end - it "should return a 400 bad request error if the name is not given" do + it "returns a 400 bad request error if the name is not given" do post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) expect(response).to have_http_status(400) end - it "should return a 401 unauthorized error if the user is not authenticated" do + it "returns a 401 unauthorized error if the user is not authenticated" do post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji"), name: 'thumbsup' expect(response).to have_http_status(401) end + it "returns a 404 error if the user authored issue" do + post api("/projects/#{project.id}/issues/#{issue2.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + end + it "normalizes +1 as thumbsup award" do post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: '+1' @@ -152,9 +187,23 @@ describe API::API, api: true do end end end + + context 'on a snippet' do + it 'creates a new award emoji' do + snippet = create(:project_snippet, :public, project: project) + + post api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji", user), name: 'blowfish' + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('blowfish') + expect(json_response['user']['username']).to eq(user.username) + end + end end describe "POST /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji" do + let(:note2) { create(:note, project: project, noteable: issue, author: user) } + it 'creates a new award emoji' do expect do post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' @@ -164,6 +213,12 @@ describe API::API, api: true do expect(json_response['user']['username']).to eq(user.username) end + it "it returns 404 error when user authored note" do + post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + end + it "normalizes +1 as thumbsup award" do post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: '+1' @@ -213,6 +268,19 @@ describe API::API, api: true do expect(response).to have_http_status(404) end end + + context 'when the awardable is a Snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet, user: user) } + + it 'deletes the award' do + expect do + delete api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji/#{award.id}", user) + end.to change { snippet.award_emoji.count }.from(1).to(0) + + expect(response).to have_http_status(200) + end + end end describe 'DELETE /projects/:id/awardable/:awardable_id/award_emoji/:award_emoji_id' do diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb new file mode 100644 index 00000000000..f4b04445c6c --- /dev/null +++ b/spec/requests/api/boards_spec.rb @@ -0,0 +1,192 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + let(:guest) { create(:user) } + let(:admin) { create(:user, :admin) } + let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) } + + let!(:dev_label) do + create(:label, title: 'Development', color: '#FFAABB', project: project) + end + + let!(:test_label) do + create(:label, title: 'Testing', color: '#FFAACC', project: project) + end + + let!(:ux_label) do + create(:label, title: 'UX', color: '#FF0000', project: project) + end + + let!(:dev_list) do + create(:list, label: dev_label, position: 1) + end + + let!(:test_list) do + create(:list, label: test_label, position: 2) + end + + let!(:board) do + create(:board, project: project, lists: [dev_list, test_list]) + end + + before do + project.team << [user, :reporter] + project.team << [guest, :guest] + end + + describe "GET /projects/:id/boards" do + let(:base_url) { "/projects/#{project.id}/boards" } + + context "when unauthenticated" do + it "returns authentication error" do + get api(base_url) + + expect(response).to have_http_status(401) + end + end + + context "when authenticated" do + it "returns the project issue board" do + get api(base_url, 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(board.id) + expect(json_response.first['lists']).to be_an Array + expect(json_response.first['lists'].length).to eq(2) + expect(json_response.first['lists'].last).to have_key('position') + end + end + end + + describe "GET /projects/:id/boards/:board_id/lists" do + let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" } + + it 'returns issue board lists' do + get api(base_url, 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['label']['name']).to eq(dev_label.title) + end + + it 'returns 404 if board not found' do + get api("/projects/#{project.id}/boards/22343/lists", user) + + expect(response).to have_http_status(404) + end + end + + describe "GET /projects/:id/boards/:board_id/lists/:list_id" do + let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" } + + it 'returns a list' do + get api("#{base_url}/#{dev_list.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(dev_list.id) + expect(json_response['label']['name']).to eq(dev_label.title) + expect(json_response['position']).to eq(1) + end + + it 'returns 404 if list not found' do + get api("#{base_url}/5324", user) + + expect(response).to have_http_status(404) + end + end + + describe "POST /projects/:id/board/lists" do + let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" } + + it 'creates a new issue board list' do + post api(base_url, user), + label_id: ux_label.id + + expect(response).to have_http_status(201) + expect(json_response['label']['name']).to eq(ux_label.title) + expect(json_response['position']).to eq(3) + end + + it 'returns 400 when creating a new list if label_id is invalid' do + post api(base_url, user), + label_id: 23423 + + expect(response).to have_http_status(400) + end + + it "returns 403 for project members with guest role" do + put api("#{base_url}/#{test_list.id}", guest), + position: 1 + + expect(response).to have_http_status(403) + end + end + + describe "PUT /projects/:id/boards/:board_id/lists/:list_id to update only position" do + let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" } + + it "updates a list" do + put api("#{base_url}/#{test_list.id}", user), + position: 1 + + expect(response).to have_http_status(200) + expect(json_response['position']).to eq(1) + end + + it "returns 404 error if list id not found" do + put api("#{base_url}/44444", user), + position: 1 + + expect(response).to have_http_status(404) + end + + it "returns 403 for project members with guest role" do + put api("#{base_url}/#{test_list.id}", guest), + position: 1 + + expect(response).to have_http_status(403) + end + end + + describe "DELETE /projects/:id/board/lists/:list_id" do + let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" } + + it "rejects a non member from deleting a list" do + delete api("#{base_url}/#{dev_list.id}", non_member) + + expect(response).to have_http_status(403) + end + + it "rejects a user with guest role from deleting a list" do + delete api("#{base_url}/#{dev_list.id}", guest) + + expect(response).to have_http_status(403) + end + + it "returns 404 error if list id not found" do + delete api("#{base_url}/44444", user) + + expect(response).to have_http_status(404) + end + + context "when the user is project owner" do + let(:owner) { create(:user) } + let(:project) { create(:project, namespace: owner.namespace) } + + it "deletes the list if an admin requests it" do + delete api("#{base_url}/#{dev_list.id}", owner) + + expect(response).to have_http_status(200) + expect(json_response['position']).to eq(1) + end + end + end +end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index e8fd697965f..3fd989dd7a6 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -13,7 +13,7 @@ describe API::API, api: true do let!(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } describe "GET /projects/:id/repository/branches" do - it "should return an array of project branches" do + it "returns an array of project branches" do project.repository.expire_cache get api("/projects/#{project.id}/repository/branches", user) @@ -25,7 +25,7 @@ describe API::API, api: true do end describe "GET /projects/:id/repository/branches/:branch" do - it "should return the branch information for a single branch" do + it "returns the branch information for a single branch" do get api("/projects/#{project.id}/repository/branches/#{branch_name}", user) expect(response).to have_http_status(200) @@ -36,12 +36,12 @@ describe API::API, api: true do expect(json_response['developers_can_merge']).to eq(false) end - it "should return a 403 error if guest" do + it "returns a 403 error if guest" do get api("/projects/#{project.id}/repository/branches", user2) expect(response).to have_http_status(403) end - it "should return a 404 error if branch is not available" do + it "returns a 404 error if branch is not available" do get api("/projects/#{project.id}/repository/branches/unknown", user) expect(response).to have_http_status(404) end @@ -138,17 +138,17 @@ describe API::API, api: true do end end - it "should return a 404 error if branch not found" do + it "returns a 404 error if branch not found" do put api("/projects/#{project.id}/repository/branches/unknown/protect", user) expect(response).to have_http_status(404) end - it "should return a 403 error if guest" do + it "returns a 403 error if guest" do put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user2) expect(response).to have_http_status(403) end - it "should return success when protect branch again" do + it "returns success when protect branch again" do put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user) put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user) expect(response).to have_http_status(200) @@ -156,7 +156,7 @@ describe API::API, api: true do end describe "PUT /projects/:id/repository/branches/:branch/unprotect" do - it "should unprotect a single branch" do + it "unprotects a single branch" do put api("/projects/#{project.id}/repository/branches/#{branch_name}/unprotect", user) expect(response).to have_http_status(200) @@ -165,12 +165,12 @@ describe API::API, api: true do expect(json_response['protected']).to eq(false) end - it "should return success when unprotect branch" do + it "returns success when unprotect branch" do put api("/projects/#{project.id}/repository/branches/unknown/unprotect", user) expect(response).to have_http_status(404) end - it "should return success when unprotect branch again" do + it "returns success when unprotect branch again" do put api("/projects/#{project.id}/repository/branches/#{branch_name}/unprotect", user) put api("/projects/#{project.id}/repository/branches/#{branch_name}/unprotect", user) expect(response).to have_http_status(200) @@ -178,7 +178,7 @@ describe API::API, api: true do end describe "POST /projects/:id/repository/branches" do - it "should create a new branch" do + it "creates a new branch" do post api("/projects/#{project.id}/repository/branches", user), branch_name: 'feature1', ref: branch_sha @@ -189,14 +189,14 @@ describe API::API, api: true do expect(json_response['commit']['id']).to eq(branch_sha) end - it "should deny for user without push access" do + it "denies for user without push access" do post api("/projects/#{project.id}/repository/branches", user2), branch_name: branch_name, ref: branch_sha expect(response).to have_http_status(403) end - it 'should return 400 if branch name is invalid' do + it 'returns 400 if branch name is invalid' do post api("/projects/#{project.id}/repository/branches", user), branch_name: 'new design', ref: branch_sha @@ -204,7 +204,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('Branch name is invalid') end - it 'should return 400 if branch already exists' do + it 'returns 400 if branch already exists' do post api("/projects/#{project.id}/repository/branches", user), branch_name: 'new_design1', ref: branch_sha @@ -217,7 +217,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('Branch already exists') end - it 'should return 400 if ref name is invalid' do + it 'returns 400 if ref name is invalid' do post api("/projects/#{project.id}/repository/branches", user), branch_name: 'new_design3', ref: 'foo' @@ -231,25 +231,25 @@ describe API::API, api: true do allow_any_instance_of(Repository).to receive(:rm_branch).and_return(true) end - it "should remove branch" do + it "removes branch" do delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) expect(response).to have_http_status(200) expect(json_response['branch_name']).to eq(branch_name) end - it 'should return 404 if branch not exists' do + it 'returns 404 if branch not exists' do delete api("/projects/#{project.id}/repository/branches/foobar", user) expect(response).to have_http_status(404) end - it "should remove protected branch" do - project.protected_branches.create(name: branch_name) + it "removes protected branch" do + create(:protected_branch, project: project, name: branch_name) delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('Protected branch cant be removed') end - it "should not remove HEAD branch" do + it "does not remove HEAD branch" do delete api("/projects/#{project.id}/repository/branches/master", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('Cannot remove HEAD branch') diff --git a/spec/requests/api/broadcast_messages_spec.rb b/spec/requests/api/broadcast_messages_spec.rb new file mode 100644 index 00000000000..7c9078b2864 --- /dev/null +++ b/spec/requests/api/broadcast_messages_spec.rb @@ -0,0 +1,180 @@ +require 'spec_helper' + +describe API::BroadcastMessages, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:admin) { create(:admin) } + + describe 'GET /broadcast_messages' do + it 'returns a 401 for anonymous users' do + get api('/broadcast_messages') + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + get api('/broadcast_messages', user) + + expect(response).to have_http_status(403) + end + + it 'returns an Array of BroadcastMessages for admins' do + create(:broadcast_message) + + get api('/broadcast_messages', admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_kind_of(Array) + expect(json_response.first.keys) + .to match_array(%w(id message starts_at ends_at color font active)) + end + end + + describe 'GET /broadcast_messages/:id' do + let!(:message) { create(:broadcast_message) } + + it 'returns a 401 for anonymous users' do + get api("/broadcast_messages/#{message.id}") + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + get api("/broadcast_messages/#{message.id}", user) + + expect(response).to have_http_status(403) + end + + it 'returns the specified message for admins' do + get api("/broadcast_messages/#{message.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq message.id + expect(json_response.keys) + .to match_array(%w(id message starts_at ends_at color font active)) + end + end + + describe 'POST /broadcast_messages' do + it 'returns a 401 for anonymous users' do + post api('/broadcast_messages'), attributes_for(:broadcast_message) + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + post api('/broadcast_messages', user), attributes_for(:broadcast_message) + + expect(response).to have_http_status(403) + end + + context 'as an admin' do + it 'requires the `message` parameter' do + attrs = attributes_for(:broadcast_message) + attrs.delete(:message) + + post api('/broadcast_messages', admin), attrs + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq 'message is missing' + end + + it 'defines sane default start and end times' do + time = Time.zone.parse('2016-07-02 10:11:12') + travel_to(time) do + post api('/broadcast_messages', admin), message: 'Test message' + + expect(response).to have_http_status(201) + expect(json_response['starts_at']).to eq '2016-07-02T10:11:12.000Z' + expect(json_response['ends_at']).to eq '2016-07-02T11:11:12.000Z' + end + end + + it 'accepts a custom background and foreground color' do + attrs = attributes_for(:broadcast_message, color: '#000000', font: '#cecece') + + post api('/broadcast_messages', admin), attrs + + expect(response).to have_http_status(201) + expect(json_response['color']).to eq attrs[:color] + expect(json_response['font']).to eq attrs[:font] + end + end + end + + describe 'PUT /broadcast_messages/:id' do + let!(:message) { create(:broadcast_message) } + + it 'returns a 401 for anonymous users' do + put api("/broadcast_messages/#{message.id}"), + attributes_for(:broadcast_message) + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + put api("/broadcast_messages/#{message.id}", user), + attributes_for(:broadcast_message) + + expect(response).to have_http_status(403) + end + + context 'as an admin' do + it 'accepts new background and foreground colors' do + attrs = { color: '#000000', font: '#cecece' } + + put api("/broadcast_messages/#{message.id}", admin), attrs + + expect(response).to have_http_status(200) + expect(json_response['color']).to eq attrs[:color] + expect(json_response['font']).to eq attrs[:font] + end + + it 'accepts new start and end times' do + time = Time.zone.parse('2016-07-02 10:11:12') + travel_to(time) do + attrs = { starts_at: Time.zone.now, ends_at: 3.hours.from_now } + + put api("/broadcast_messages/#{message.id}", admin), attrs + + expect(response).to have_http_status(200) + expect(json_response['starts_at']).to eq '2016-07-02T10:11:12.000Z' + expect(json_response['ends_at']).to eq '2016-07-02T13:11:12.000Z' + end + end + + it 'accepts a new message' do + attrs = { message: 'new message' } + + put api("/broadcast_messages/#{message.id}", admin), attrs + + expect(response).to have_http_status(200) + expect { message.reload }.to change { message.message }.to('new message') + end + end + end + + describe 'DELETE /broadcast_messages/:id' do + let!(:message) { create(:broadcast_message) } + + it 'returns a 401 for anonymous users' do + delete api("/broadcast_messages/#{message.id}"), + attributes_for(:broadcast_message) + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + delete api("/broadcast_messages/#{message.id}", user), + attributes_for(:broadcast_message) + + expect(response).to have_http_status(403) + end + + it 'deletes the broadcast message for admins' do + expect { delete api("/broadcast_messages/#{message.id}", admin) } + .to change { BroadcastMessage.count }.by(-1) + end + end +end diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb index 86a7b242fbe..95c7bbf99c9 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/builds_spec.rb @@ -9,16 +9,18 @@ describe API::API, api: true do let!(:developer) { create(:project_member, :developer, user: user, project: project) } let(:reporter) { create(:project_member, :reporter, project: project) } let(:guest) { create(:project_member, :guest, project: project) } - let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) } + let!(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) } let!(:build) { create(:ci_build, pipeline: pipeline) } describe 'GET /projects/:id/builds ' do let(:query) { '' } - before { get api("/projects/#{project.id}/builds?#{query}", api_user) } + before do + get api("/projects/#{project.id}/builds?#{query}", api_user) + end context 'authorized user' do - it 'should return project builds' do + it 'returns project builds' do expect(response).to have_http_status(200) expect(json_response).to be_an Array end @@ -28,6 +30,15 @@ describe API::API, api: true do expect(json_response.first['commit']['id']).to eq project.commit.id end + it 'returns pipeline data' do + json_build = json_response.first + expect(json_build['pipeline']).not_to be_empty + expect(json_build['pipeline']['id']).to eq build.pipeline.id + expect(json_build['pipeline']['ref']).to eq build.pipeline.ref + expect(json_build['pipeline']['sha']).to eq build.pipeline.sha + expect(json_build['pipeline']['status']).to eq build.pipeline.status + end + context 'filter project with one scope element' do let(:query) { 'scope=pending' } @@ -84,11 +95,20 @@ describe API::API, api: true do get api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", api_user) end - it 'should return project builds for specific commit' do + it 'returns project builds for specific commit' do expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.size).to eq 2 end + + it 'returns pipeline data' do + json_build = json_response.first + expect(json_build['pipeline']).not_to be_empty + expect(json_build['pipeline']['id']).to eq build.pipeline.id + expect(json_build['pipeline']['ref']).to eq build.pipeline.ref + expect(json_build['pipeline']['sha']).to eq build.pipeline.sha + expect(json_build['pipeline']['status']).to eq build.pipeline.status + end end context 'when pipeline has no builds' do @@ -113,7 +133,7 @@ describe API::API, api: true do get api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", nil) end - it 'should not return project builds' do + it 'does not return project builds' do expect(response).to have_http_status(401) expect(json_response.except('message')).to be_empty end @@ -122,26 +142,39 @@ describe API::API, api: true do end describe 'GET /projects/:id/builds/:build_id' do - before { get api("/projects/#{project.id}/builds/#{build.id}", api_user) } + before do + get api("/projects/#{project.id}/builds/#{build.id}", api_user) + end context 'authorized user' do - it 'should return specific build data' do + it 'returns specific build data' do expect(response).to have_http_status(200) expect(json_response['name']).to eq('test') end + + it 'returns pipeline data' do + json_build = json_response + expect(json_build['pipeline']).not_to be_empty + expect(json_build['pipeline']['id']).to eq build.pipeline.id + expect(json_build['pipeline']['ref']).to eq build.pipeline.ref + expect(json_build['pipeline']['sha']).to eq build.pipeline.sha + expect(json_build['pipeline']['status']).to eq build.pipeline.status + end end context 'unauthorized user' do let(:api_user) { nil } - it 'should not return specific build data' do + it 'does not return specific build data' do expect(response).to have_http_status(401) end end end describe 'GET /projects/:id/builds/:build_id/artifacts' do - before { get api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) } + before do + get api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) + end context 'build with artifacts' do let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } @@ -152,7 +185,7 @@ describe API::API, api: true do 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } end - it 'should return specific build artifacts' do + it 'returns specific build artifacts' do expect(response).to have_http_status(200) expect(response.headers).to include(download_headers) end @@ -161,20 +194,24 @@ describe API::API, api: true do context 'unauthorized user' do let(:api_user) { nil } - it 'should not return specific build artifacts' do + it 'does not return specific build artifacts' do expect(response).to have_http_status(401) end end end - it 'should not return build artifacts if not uploaded' do + it 'does not return build artifacts if not uploaded' do expect(response).to have_http_status(404) end end describe 'GET /projects/:id/artifacts/:ref_name/download?job=name' do let(:api_user) { reporter.user } - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + before do + build.success + end def path_for_ref(ref = pipeline.ref, job = build.name) api("/projects/#{project.id}/builds/artifacts/#{ref}/download?job=#{job}", api_user) @@ -272,7 +309,7 @@ describe API::API, api: true do end context 'authorized user' do - it 'should return specific build trace' do + it 'returns specific build trace' do expect(response).to have_http_status(200) expect(response.body).to eq(build.trace) end @@ -281,18 +318,20 @@ describe API::API, api: true do context 'unauthorized user' do let(:api_user) { nil } - it 'should not return specific build trace' do + it 'does not return specific build trace' do expect(response).to have_http_status(401) end end end describe 'POST /projects/:id/builds/:build_id/cancel' do - before { post api("/projects/#{project.id}/builds/#{build.id}/cancel", api_user) } + before do + post api("/projects/#{project.id}/builds/#{build.id}/cancel", api_user) + end context 'authorized user' do context 'user with :update_build persmission' do - it 'should cancel running or pending build' do + it 'cancels running or pending build' do expect(response).to have_http_status(201) expect(project.builds.first.status).to eq('canceled') end @@ -301,7 +340,7 @@ describe API::API, api: true do context 'user without :update_build permission' do let(:api_user) { reporter.user } - it 'should not cancel build' do + it 'does not cancel build' do expect(response).to have_http_status(403) end end @@ -310,7 +349,7 @@ describe API::API, api: true do context 'unauthorized user' do let(:api_user) { nil } - it 'should not cancel build' do + it 'does not cancel build' do expect(response).to have_http_status(401) end end @@ -319,11 +358,13 @@ describe API::API, api: true do describe 'POST /projects/:id/builds/:build_id/retry' do let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } - before { post api("/projects/#{project.id}/builds/#{build.id}/retry", api_user) } + before do + post api("/projects/#{project.id}/builds/#{build.id}/retry", api_user) + end context 'authorized user' do context 'user with :update_build permission' do - it 'should retry non-running build' do + it 'retries non-running build' do expect(response).to have_http_status(201) expect(project.builds.first.status).to eq('canceled') expect(json_response['status']).to eq('pending') @@ -333,7 +374,7 @@ describe API::API, api: true do context 'user without :update_build permission' do let(:api_user) { reporter.user } - it 'should not retry build' do + it 'does not retry build' do expect(response).to have_http_status(403) end end @@ -342,7 +383,7 @@ describe API::API, api: true do context 'unauthorized user' do let(:api_user) { nil } - it 'should not retry build' do + it 'does not retry build' do expect(response).to have_http_status(401) end end @@ -356,14 +397,14 @@ describe API::API, api: true do context 'build is erasable' do let(:build) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline) } - it 'should erase build content' do + it 'erases build content' do expect(response.status).to eq 201 expect(build.trace).to be_empty expect(build.artifacts_file.exists?).to be_falsy expect(build.artifacts_metadata.exists?).to be_falsy end - it 'should update build' do + it 'updates build' do expect(build.reload.erased_at).to be_truthy expect(build.reload.erased_by).to eq user end @@ -372,7 +413,7 @@ describe API::API, api: true do context 'build is not erasable' do let(:build) { create(:ci_build, :trace, project: project, pipeline: pipeline) } - it 'should respond with forbidden' do + it 'responds with forbidden' do expect(response.status).to eq 403 end end @@ -403,4 +444,27 @@ describe API::API, api: true do end end end + + describe 'POST /projects/:id/builds/:build_id/play' do + before do + post api("/projects/#{project.id}/builds/#{build.id}/play", user) + end + + context 'on an playable build' do + let(:build) { create(:ci_build, :manual, project: project, pipeline: pipeline) } + + it 'plays the build' do + expect(response).to have_http_status 200 + expect(json_response['user']['id']).to eq(user.id) + expect(json_response['id']).to eq(build.id) + end + end + + context 'on a non-playable build' do + it 'returns a status code 400, Bad Request' do + expect(response).to have_http_status 400 + expect(response.body).to match("Unplayable Build") + end + end + end end diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 2da01da7fa1..7aa7e85a9e2 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -99,7 +99,7 @@ describe API::CommitStatuses, api: true do context "guest user" do before { get api(get_url, guest) } - it "should not return project commits" do + it "does not return project commits" do expect(response).to have_http_status(403) end end @@ -107,7 +107,7 @@ describe API::CommitStatuses, api: true do context "unauthorized user" do before { get api(get_url) } - it "should not return project commits" do + it "does not return project commits" do expect(response).to have_http_status(401) end end @@ -117,17 +117,36 @@ describe API::CommitStatuses, api: true do let(:post_url) { "/projects/#{project.id}/statuses/#{sha}" } context 'developer user' do - context 'only required parameters' do - before { post api(post_url, developer), state: 'success' } + %w[pending running success failed canceled].each do |status| + context "for #{status}" do + context 'uses only required parameters' do + it 'creates commit status' do + post api(post_url, developer), state: status + + expect(response).to have_http_status(201) + expect(json_response['sha']).to eq(commit.id) + expect(json_response['status']).to eq(status) + expect(json_response['name']).to eq('default') + expect(json_response['ref']).not_to be_empty + expect(json_response['target_url']).to be_nil + expect(json_response['description']).to be_nil + end + end + end + end - it 'creates commit status' do - expect(response).to have_http_status(201) - expect(json_response['sha']).to eq(commit.id) - expect(json_response['status']).to eq('success') - expect(json_response['name']).to eq('default') - expect(json_response['ref']).to be_nil - expect(json_response['target_url']).to be_nil - expect(json_response['description']).to be_nil + context 'transitions status from pending' do + before do + post api(post_url, developer), state: 'pending' + end + + %w[running success failed canceled].each do |status| + it "to #{status}" do + expect { post api(post_url, developer), state: status }.not_to change { CommitStatus.count } + + expect(response).to have_http_status(201) + expect(json_response['status']).to eq(status) + end end end @@ -179,7 +198,7 @@ describe API::CommitStatuses, api: true do context 'reporter user' do before { post api(post_url, reporter) } - it 'should not create commit status' do + it 'does not create commit status' do expect(response).to have_http_status(403) end end @@ -187,7 +206,7 @@ describe API::CommitStatuses, api: true do context 'guest user' do before { post api(post_url, guest) } - it 'should not create commit status' do + it 'does not create commit status' do expect(response).to have_http_status(403) end end @@ -195,7 +214,7 @@ describe API::CommitStatuses, api: true do context 'unauthorized user' do before { post api(post_url) } - it 'should not create commit status' do + it 'does not create commit status' do expect(response).to have_http_status(401) end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 51ee2167d47..66fa0c0c01f 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -5,7 +5,7 @@ describe API::API, api: true do include ApiHelpers let(:user) { create(:user) } let(:user2) { create(:user) } - let!(:project) { create(:project, creator_id: user.id) } + let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let!(:master) { create(:project_member, :master, user: user, project: project) } let!(:guest) { create(:project_member, :guest, user: user2, project: project) } let!(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'a comment on a commit') } @@ -13,11 +13,11 @@ describe API::API, api: true do before { project.team << [user, :reporter] } - describe "GET /projects/:id/repository/commits" do + describe "List repository commits" do context "authorized user" do before { project.team << [user2, :reporter] } - it "should return project commits" do + it "returns project commits" do get api("/projects/#{project.id}/repository/commits", user) expect(response).to have_http_status(200) @@ -27,14 +27,14 @@ describe API::API, api: true do end context "unauthorized user" do - it "should not return project commits" do + it "does not return project commits" do get api("/projects/#{project.id}/repository/commits") expect(response).to have_http_status(401) end end context "since optional parameter" do - it "should return project commits since provided parameter" do + it "returns project commits since provided parameter" do commits = project.repository.commits("master") since = commits.second.created_at @@ -47,20 +47,25 @@ describe API::API, api: true do end context "until optional parameter" do - it "should return project commits until provided parameter" do + it "returns project commits until provided parameter" do commits = project.repository.commits("master") before = commits.second.created_at get api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user) - expect(json_response.size).to eq(commits.size - 1) + if commits.size >= 20 + expect(json_response.size).to eq(20) + else + expect(json_response.size).to eq(commits.size - 1) + end + expect(json_response.first["id"]).to eq(commits.second.id) expect(json_response.second["id"]).to eq(commits.third.id) end end context "invalid xmlschema date parameters" do - it "should return an invalid parameter error message" do + it "returns an invalid parameter error message" do get api("/projects/#{project.id}/repository/commits?since=invalid-date", user) expect(response).to have_http_status(400) @@ -69,9 +74,270 @@ describe API::API, api: true do end end - describe "GET /projects:id/repository/commits/:sha" do + describe "Create a commit with multiple files and actions" do + let!(:url) { "/projects/#{project.id}/repository/commits" } + + it 'returns a 403 unauthorized for user without permissions' do + post api(url, user2) + + expect(response).to have_http_status(403) + end + + it 'returns a 400 bad request if no params are given' do + post api(url, user) + + expect(response).to have_http_status(400) + end + + context :create do + let(:message) { 'Created file' } + let!(:invalid_c_params) do + { + branch_name: 'master', + commit_message: message, + actions: [ + { + action: 'create', + file_path: 'files/ruby/popen.rb', + content: 'puts 8' + } + ] + } + end + let!(:valid_c_params) do + { + branch_name: 'master', + commit_message: message, + actions: [ + { + action: 'create', + file_path: 'foo/bar/baz.txt', + content: 'puts 8' + } + ] + } + end + + it 'a new file in project repo' do + post api(url, user), valid_c_params + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq(message) + end + + it 'returns a 400 bad request if file exists' do + post api(url, user), invalid_c_params + + expect(response).to have_http_status(400) + end + end + + context :delete do + let(:message) { 'Deleted file' } + let!(:invalid_d_params) do + { + branch_name: 'markdown', + commit_message: message, + actions: [ + { + action: 'delete', + file_path: 'doc/api/projects.md' + } + ] + } + end + let!(:valid_d_params) do + { + branch_name: 'markdown', + commit_message: message, + actions: [ + { + action: 'delete', + file_path: 'doc/api/users.md' + } + ] + } + end + + it 'an existing file in project repo' do + post api(url, user), valid_d_params + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq(message) + end + + it 'returns a 400 bad request if file does not exist' do + post api(url, user), invalid_d_params + + expect(response).to have_http_status(400) + end + end + + context :move do + let(:message) { 'Moved file' } + let!(:invalid_m_params) do + { + branch_name: 'feature', + commit_message: message, + actions: [ + { + action: 'move', + file_path: 'CHANGELOG', + previous_path: 'VERSION', + content: '6.7.0.pre' + } + ] + } + end + let!(:valid_m_params) do + { + branch_name: 'feature', + commit_message: message, + actions: [ + { + action: 'move', + file_path: 'VERSION.txt', + previous_path: 'VERSION', + content: '6.7.0.pre' + } + ] + } + end + + it 'an existing file in project repo' do + post api(url, user), valid_m_params + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq(message) + end + + it 'returns a 400 bad request if file does not exist' do + post api(url, user), invalid_m_params + + expect(response).to have_http_status(400) + end + end + + context :update do + let(:message) { 'Updated file' } + let!(:invalid_u_params) do + { + branch_name: 'master', + commit_message: message, + actions: [ + { + action: 'update', + file_path: 'foo/bar.baz', + content: 'puts 8' + } + ] + } + end + let!(:valid_u_params) do + { + branch_name: 'master', + commit_message: message, + actions: [ + { + action: 'update', + file_path: 'files/ruby/popen.rb', + content: 'puts 8' + } + ] + } + end + + it 'an existing file in project repo' do + post api(url, user), valid_u_params + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq(message) + end + + it 'returns a 400 bad request if file does not exist' do + post api(url, user), invalid_u_params + + expect(response).to have_http_status(400) + end + end + + context "multiple operations" do + let(:message) { 'Multiple actions' } + let!(:invalid_mo_params) do + { + branch_name: 'master', + commit_message: message, + actions: [ + { + action: 'create', + file_path: 'files/ruby/popen.rb', + content: 'puts 8' + }, + { + action: 'delete', + file_path: 'doc/api/projects.md' + }, + { + action: 'move', + file_path: 'CHANGELOG', + previous_path: 'VERSION', + content: '6.7.0.pre' + }, + { + action: 'update', + file_path: 'foo/bar.baz', + content: 'puts 8' + } + ] + } + end + let!(:valid_mo_params) do + { + branch_name: 'master', + commit_message: message, + actions: [ + { + action: 'create', + file_path: 'foo/bar/baz.txt', + content: 'puts 8' + }, + { + action: 'delete', + file_path: 'Gemfile.zip' + }, + { + action: 'move', + file_path: 'VERSION.txt', + previous_path: 'VERSION', + content: '6.7.0.pre' + }, + { + action: 'update', + file_path: 'files/ruby/popen.rb', + content: 'puts 8' + } + ] + } + end + + it 'are commited as one in project repo' do + post api(url, user), valid_mo_params + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq(message) + end + + it 'return a 400 bad request if there are any issues' do + post api(url, user), invalid_mo_params + + expect(response).to have_http_status(400) + end + end + end + + describe "Get a single commit" do context "authorized user" do - it "should return a commit by sha" do + it "returns a commit by sha" do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) expect(response).to have_http_status(200) @@ -82,38 +348,51 @@ describe API::API, api: true do expect(json_response['stats']['total']).to eq(project.repository.commit.stats.total) end - it "should return a 404 error if not found" do + it "returns a 404 error if not found" do get api("/projects/#{project.id}/repository/commits/invalid_sha", user) expect(response).to have_http_status(404) end - it "should return nil for commit without CI" do + it "returns nil for commit without CI" do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + expect(response).to have_http_status(200) expect(json_response['status']).to be_nil end - it "should return status for CI" do - pipeline = project.ensure_pipeline(project.repository.commit.sha, 'master') + it "returns status for CI" do + pipeline = project.ensure_pipeline('master', project.repository.commit.sha) + pipeline.update(status: 'success') + get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + expect(response).to have_http_status(200) expect(json_response['status']).to eq(pipeline.status) end + + it "returns status for CI when pipeline is created" do + project.ensure_pipeline('master', project.repository.commit.sha) + + get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq("created") + end end context "unauthorized user" do - it "should not return the selected commit" do + it "does not return the selected commit" do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}") expect(response).to have_http_status(401) end end end - describe "GET /projects:id/repository/commits/:sha/diff" do + describe "Get the diff of a commit" do context "authorized user" do before { project.team << [user2, :reporter] } - it "should return the diff of the selected commit" do + it "returns the diff of the selected commit" do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff", user) expect(response).to have_http_status(200) @@ -122,23 +401,23 @@ describe API::API, api: true do expect(json_response.first.keys).to include "diff" end - it "should return a 404 error if invalid commit" do + it "returns a 404 error if invalid commit" do get api("/projects/#{project.id}/repository/commits/invalid_sha/diff", user) expect(response).to have_http_status(404) end end context "unauthorized user" do - it "should not return the diff of the selected commit" do + it "does not return the diff of the selected commit" do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff") expect(response).to have_http_status(401) end end end - describe 'GET /projects:id/repository/commits/:sha/comments' do + describe 'Get the comments of a commit' do context 'authorized user' do - it 'should return merge_request comments' do + it 'returns merge_request comments' do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -147,23 +426,23 @@ describe API::API, api: true do expect(json_response.first['author']['id']).to eq(user.id) end - it 'should return a 404 error if merge_request_id not found' do + it 'returns a 404 error if merge_request_id not found' do get api("/projects/#{project.id}/repository/commits/1234ab/comments", user) expect(response).to have_http_status(404) end end context 'unauthorized user' do - it 'should not return the diff of the selected commit' do + it 'does not return the diff of the selected commit' do get api("/projects/#{project.id}/repository/commits/1234ab/comments") expect(response).to have_http_status(401) end end end - describe 'POST /projects:id/repository/commits/:sha/comments' do + describe 'Post comment to commit' do context 'authorized user' do - it 'should return comment' do + it 'returns comment' do post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment' expect(response).to have_http_status(201) expect(json_response['note']).to eq('My comment') @@ -172,28 +451,29 @@ describe API::API, api: true do expect(json_response['line_type']).to be_nil end - it 'should return the inline comment' do - post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 7, line_type: 'new' + it 'returns the inline comment' do + post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 1, line_type: 'new' + expect(response).to have_http_status(201) expect(json_response['note']).to eq('My comment') expect(json_response['path']).to eq(project.repository.commit.raw_diffs.first.new_path) - expect(json_response['line']).to eq(7) + expect(json_response['line']).to eq(1) expect(json_response['line_type']).to eq('new') end - it 'should return 400 if note is missing' do + it 'returns 400 if note is missing' do post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user) expect(response).to have_http_status(400) end - it 'should return 404 if note is attached to non existent commit' do + it 'returns 404 if note is attached to non existent commit' do post api("/projects/#{project.id}/repository/commits/1234ab/comments", user), note: 'My comment' expect(response).to have_http_status(404) end end context 'unauthorized user' do - it 'should not return the diff of the selected commit' do + it 'does not return the diff of the selected commit' do post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments") expect(response).to have_http_status(401) end diff --git a/spec/requests/api/deploy_keys.rb b/spec/requests/api/deploy_keys.rb deleted file mode 100644 index ac42288bc34..00000000000 --- a/spec/requests/api/deploy_keys.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'spec_helper' - -describe API::API, api: true do - include ApiHelpers - - let(:user) { create(:user) } - let(:project) { create(:project, creator_id: user.id) } - let!(:deploy_keys_project) { create(:deploy_keys_project, project: project) } - let(:admin) { create(:admin) } - - describe 'GET /deploy_keys' do - before { admin } - - context 'when unauthenticated' do - it 'should return authentication error' do - get api('/deploy_keys') - expect(response.status).to eq(401) - end - end - - context 'when authenticated as non-admin user' do - it 'should return a 403 error' do - get api('/deploy_keys', user) - expect(response.status).to eq(403) - end - end - - context 'when authenticated as admin' do - it 'should return all deploy keys' do - get api('/deploy_keys', admin) - expect(response.status).to eq(200) - - expect(json_response).to be_an Array - expect(json_response.first['id']).to eq(deploy_keys_project.deploy_key.id) - end - end - end -end diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb new file mode 100644 index 00000000000..7d8cc45327c --- /dev/null +++ b/spec/requests/api/deploy_keys_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:admin) { create(:admin) } + let(:project) { create(:project, creator_id: user.id) } + let(:deploy_key) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key) + end + + describe 'GET /deploy_keys' do + context 'when unauthenticated' do + it 'should return authentication error' do + get api('/deploy_keys') + + expect(response.status).to eq(401) + end + end + + context 'when authenticated as non-admin user' do + it 'should return a 403 error' do + get api('/deploy_keys', user) + + expect(response.status).to eq(403) + end + end + + context 'when authenticated as admin' do + it 'should return all deploy keys' do + get api('/deploy_keys', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.first['id']).to eq(deploy_keys_project.deploy_key.id) + end + end + end + + describe 'GET /projects/:id/deploy_keys' do + before { deploy_key } + + it 'should return array of ssh keys' do + get api("/projects/#{project.id}/deploy_keys", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['title']).to eq(deploy_key.title) + end + end + + describe 'GET /projects/:id/deploy_keys/:key_id' do + it 'should return a single key' do + get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(deploy_key.title) + end + + it 'should return 404 Not Found with invalid ID' do + get api("/projects/#{project.id}/deploy_keys/404", admin) + + expect(response).to have_http_status(404) + end + end + + describe 'POST /projects/:id/deploy_keys' do + it 'should not create an invalid ssh key' do + post api("/projects/#{project.id}/deploy_keys", admin), { title: 'invalid key' } + + expect(response).to have_http_status(400) + expect(json_response['message']['key']).to eq([ + 'can\'t be blank', + 'is too short (minimum is 0 characters)', + 'is invalid' + ]) + end + + it 'should not create a key without title' do + post api("/projects/#{project.id}/deploy_keys", admin), key: 'some key' + + expect(response).to have_http_status(400) + expect(json_response['message']['title']).to eq([ + 'can\'t be blank', + 'is too short (minimum is 0 characters)' + ]) + end + + it 'should create new ssh key' do + key_attrs = attributes_for :another_key + + expect do + post api("/projects/#{project.id}/deploy_keys", admin), key_attrs + end.to change{ project.deploy_keys.count }.by(1) + end + end + + describe 'DELETE /projects/:id/deploy_keys/:key_id' do + before { deploy_key } + + it 'should delete existing key' do + expect do + delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) + end.to change{ project.deploy_keys.count }.by(-1) + end + + it 'should return 404 Not Found with invalid ID' do + delete api("/projects/#{project.id}/deploy_keys/404", admin) + + expect(response).to have_http_status(404) + end + end + + describe 'POST /projects/:id/deploy_keys/:key_id/enable' do + let(:project2) { create(:empty_project) } + + context 'when the user can admin the project' do + it 'enables the key' do + expect do + post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", admin) + end.to change { project2.deploy_keys.count }.from(0).to(1) + + expect(response).to have_http_status(201) + expect(json_response['id']).to eq(deploy_key.id) + end + end + + context 'when authenticated as non-admin user' do + it 'should return a 404 error' do + post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", user) + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /projects/:id/deploy_keys/:key_id/disable' do + context 'when the user can admin the project' do + it 'disables the key' do + expect do + delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}/disable", admin) + end.to change { project.deploy_keys.count }.from(1).to(0) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(deploy_key.id) + end + end + + context 'when authenticated as non-admin user' do + it 'should return a 404 error' do + delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}/disable", user) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb new file mode 100644 index 00000000000..8fa8c66db6c --- /dev/null +++ b/spec/requests/api/deployments_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { deployment.environment.project } + let!(:deployment) { create(:deployment) } + + before do + project.team << [user, :master] + end + + describe 'GET /projects/:id/deployments' do + context 'as member of the project' do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/projects/#{project.id}/deployments", user) } + end + + it 'returns projects deployments' do + get api("/projects/#{project.id}/deployments", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['iid']).to eq(deployment.iid) + expect(json_response.first['sha']).to match /\A\h{40}\z/ + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments", non_member) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/deployments/:deployment_id' do + context 'as a member of the project' do + it 'returns the projects deployment' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + expect(json_response['id']).to eq(deployment.id) + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index 05e57905343..1898b07835d 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -26,6 +26,7 @@ describe API::API, api: true do expect(json_response.size).to eq(1) expect(json_response.first['name']).to eq(environment.name) expect(json_response.first['external_url']).to eq(environment.external_url) + expect(json_response.first['project']['id']).to eq(project.id) end end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 2e5448143d5..050d0dd082d 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -5,17 +5,33 @@ describe API::API, api: true do let(:user) { create(:user) } let!(:project) { create(:project, namespace: user.namespace ) } let(:file_path) { 'files/ruby/popen.rb' } + let(:author_email) { FFaker::Internet.email } + + # I have to remove periods from the end of the name + # This happened when the user's name had a suffix (i.e. "Sr.") + # This seems to be what git does under the hood. For example, this commit: + # + # $ git commit --author='Foo Sr. <foo@example.com>' -m 'Where's my trailing period?' + # + # results in this: + # + # $ git show --pretty + # ... + # Author: Foo Sr <foo@example.com> + # ... + let(:author_name) { FFaker::Name.name.chomp("\.") } before { project.team << [user, :developer] } describe "GET /projects/:id/repository/files" do - it "should return file info" do + it "returns file info" do params = { file_path: file_path, ref: 'master', } get api("/projects/#{project.id}/repository/files", user), params + expect(response).to have_http_status(200) expect(json_response['file_path']).to eq(file_path) expect(json_response['file_name']).to eq('popen.rb') @@ -23,18 +39,20 @@ describe API::API, api: true do expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n") end - it "should return a 400 bad request if no params given" do + it "returns a 400 bad request if no params given" do get api("/projects/#{project.id}/repository/files", user) + expect(response).to have_http_status(400) end - it "should return a 404 if such file does not exist" do + it "returns a 404 if such file does not exist" do params = { file_path: 'app/models/application.rb', ref: 'master', } get api("/projects/#{project.id}/repository/files", user), params + expect(response).to have_http_status(404) end end @@ -49,24 +67,43 @@ describe API::API, api: true do } end - it "should create a new file in project repo" do + it "creates a new file in project repo" do post api("/projects/#{project.id}/repository/files", user), valid_params + expect(response).to have_http_status(201) expect(json_response['file_path']).to eq('newfile.rb') + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(user.email) + expect(last_commit.author_name).to eq(user.name) end - it "should return a 400 bad request if no params given" do + it "returns a 400 bad request if no params given" do post api("/projects/#{project.id}/repository/files", user) + expect(response).to have_http_status(400) end - it "should return a 400 if editor fails to create file" do + it "returns a 400 if editor fails to create file" do allow_any_instance_of(Repository).to receive(:commit_file). and_return(false) post api("/projects/#{project.id}/repository/files", user), valid_params + expect(response).to have_http_status(400) end + + context "when specifying an author" do + it "creates a new file with the specified author" do + valid_params.merge!(author_email: author_email, author_name: author_name) + + post api("/projects/#{project.id}/repository/files", user), valid_params + + expect(response).to have_http_status(201) + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end end describe "PUT /projects/:id/repository/files" do @@ -79,16 +116,34 @@ describe API::API, api: true do } end - it "should update existing file in project repo" do + it "updates existing file in project repo" do put api("/projects/#{project.id}/repository/files", user), valid_params + expect(response).to have_http_status(200) expect(json_response['file_path']).to eq(file_path) + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(user.email) + expect(last_commit.author_name).to eq(user.name) end - it "should return a 400 bad request if no params given" do + it "returns a 400 bad request if no params given" do put api("/projects/#{project.id}/repository/files", user) + expect(response).to have_http_status(400) end + + context "when specifying an author" do + it "updates a file with the specified author" do + valid_params.merge!(author_email: author_email, author_name: author_name, content: "New content") + + put api("/projects/#{project.id}/repository/files", user), valid_params + + expect(response).to have_http_status(200) + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end end describe "DELETE /projects/:id/repository/files" do @@ -100,23 +155,42 @@ describe API::API, api: true do } end - it "should delete existing file in project repo" do + it "deletes existing file in project repo" do delete api("/projects/#{project.id}/repository/files", user), valid_params + expect(response).to have_http_status(200) expect(json_response['file_path']).to eq(file_path) + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(user.email) + expect(last_commit.author_name).to eq(user.name) end - it "should return a 400 bad request if no params given" do + it "returns a 400 bad request if no params given" do delete api("/projects/#{project.id}/repository/files", user) + expect(response).to have_http_status(400) end - it "should return a 400 if fails to create file" do + it "returns a 400 if fails to create file" do allow_any_instance_of(Repository).to receive(:remove_file).and_return(false) delete api("/projects/#{project.id}/repository/files", user), valid_params + expect(response).to have_http_status(400) end + + context "when specifying an author" do + it "removes a file with the specified author" do + valid_params.merge!(author_email: author_email, author_name: author_name) + + delete api("/projects/#{project.id}/repository/files", user), valid_params + + expect(response).to have_http_status(200) + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(author_email) + expect(last_commit.author_name).to eq(author_name) + end + end end describe "POST /projects/:id/repository/files with binary file" do @@ -143,6 +217,7 @@ describe API::API, api: true do it "remains unchanged" do get api("/projects/#{project.id}/repository/files", user), get_params + expect(response).to have_http_status(200) expect(json_response['file_path']).to eq(file_path) expect(json_response['file_name']).to eq(file_path) diff --git a/spec/requests/api/fork_spec.rb b/spec/requests/api/fork_spec.rb index a9f5aa924b7..e38d5745d44 100644 --- a/spec/requests/api/fork_spec.rb +++ b/spec/requests/api/fork_spec.rb @@ -6,13 +6,19 @@ describe API::API, api: true do let(:user2) { create(:user) } let(:user3) { create(:user) } let(:admin) { create(:admin) } + let(:group) { create(:group) } + let(:group2) do + group = create(:group, name: 'group2_name') + group.add_owner(user2) + group + end let(:project) do create(:project, creator_id: user.id, namespace: user.namespace) end let(:project_user2) do - create(:project_member, :guest, user: user2, project: project) + create(:project_member, :reporter, user: user2, project: project) end describe 'POST /projects/fork/:id' do @@ -20,8 +26,9 @@ describe API::API, api: true do before { user3 } context 'when authenticated' do - it 'should fork if user has sufficient access to project' do + it 'forks if user has sufficient access to project' do post api("/projects/fork/#{project.id}", user2) + expect(response).to have_http_status(201) expect(json_response['name']).to eq(project.name) expect(json_response['path']).to eq(project.path) @@ -30,8 +37,9 @@ describe API::API, api: true do expect(json_response['forked_from_project']['id']).to eq(project.id) end - it 'should fork if user is admin' do + it 'forks if user is admin' do post api("/projects/fork/#{project.id}", admin) + expect(response).to have_http_status(201) expect(json_response['name']).to eq(project.name) expect(json_response['path']).to eq(project.path) @@ -40,29 +48,87 @@ describe API::API, api: true do expect(json_response['forked_from_project']['id']).to eq(project.id) end - it 'should fail on missing project access for the project to fork' do + it 'fails on missing project access for the project to fork' do post api("/projects/fork/#{project.id}", user3) + expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Project Not Found') end - it 'should fail if forked project exists in the user namespace' do + it 'fails if forked project exists in the user namespace' do post api("/projects/fork/#{project.id}", user) + expect(response).to have_http_status(409) expect(json_response['message']['name']).to eq(['has already been taken']) expect(json_response['message']['path']).to eq(['has already been taken']) end - it 'should fail if project to fork from does not exist' do + it 'fails if project to fork from does not exist' do post api('/projects/fork/424242', user) + expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Project Not Found') end + + it 'forks with explicit own user namespace id' do + post api("/projects/fork/#{project.id}", user2), namespace: user2.namespace.id + + expect(response).to have_http_status(201) + expect(json_response['owner']['id']).to eq(user2.id) + end + + it 'forks with explicit own user name as namespace' do + post api("/projects/fork/#{project.id}", user2), namespace: user2.username + + expect(response).to have_http_status(201) + expect(json_response['owner']['id']).to eq(user2.id) + end + + it 'forks to another user when admin' do + post api("/projects/fork/#{project.id}", admin), namespace: user2.username + + expect(response).to have_http_status(201) + expect(json_response['owner']['id']).to eq(user2.id) + end + + it 'fails if trying to fork to another user when not admin' do + post api("/projects/fork/#{project.id}", user2), namespace: admin.namespace.id + + expect(response).to have_http_status(404) + end + + it 'fails if trying to fork to non-existent namespace' do + post api("/projects/fork/#{project.id}", user2), namespace: 42424242 + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Target Namespace Not Found') + end + + it 'forks to owned group' do + post api("/projects/fork/#{project.id}", user2), namespace: group2.name + + expect(response).to have_http_status(201) + expect(json_response['namespace']['name']).to eq(group2.name) + end + + it 'fails to fork to not owned group' do + post api("/projects/fork/#{project.id}", user2), namespace: group.name + + expect(response).to have_http_status(404) + end + + it 'forks to not owned group when admin' do + post api("/projects/fork/#{project.id}", admin), namespace: group.name + + expect(response).to have_http_status(201) + expect(json_response['namespace']['name']).to eq(group.name) + end end context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do post api("/projects/fork/#{project.id}") + expect(response).to have_http_status(401) expect(json_response['message']).to eq('401 Unauthorized') end diff --git a/spec/requests/api/group_members_spec.rb b/spec/requests/api/group_members_spec.rb deleted file mode 100644 index 52f9e7d4681..00000000000 --- a/spec/requests/api/group_members_spec.rb +++ /dev/null @@ -1,199 +0,0 @@ -require 'spec_helper' - -describe API::API, api: true do - include ApiHelpers - - let(:owner) { create(:user) } - let(:reporter) { create(:user) } - let(:developer) { create(:user) } - let(:master) { create(:user) } - let(:guest) { create(:user) } - let(:stranger) { create(:user) } - - let!(:group_with_members) do - group = create(:group, :private) - group.add_users([reporter.id], GroupMember::REPORTER) - group.add_users([developer.id], GroupMember::DEVELOPER) - group.add_users([master.id], GroupMember::MASTER) - group.add_users([guest.id], GroupMember::GUEST) - group - end - - let!(:group_no_members) { create(:group) } - - before do - group_with_members.add_owner owner - group_no_members.add_owner owner - end - - describe "GET /groups/:id/members" do - context "when authenticated as user that is part or the group" do - it "each user: should return an array of members groups of group3" do - [owner, master, developer, reporter, guest].each do |user| - get api("/groups/#{group_with_members.id}/members", user) - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.size).to eq(5) - expect(json_response.find { |e| e['id'] == owner.id }['access_level']).to eq(GroupMember::OWNER) - expect(json_response.find { |e| e['id'] == reporter.id }['access_level']).to eq(GroupMember::REPORTER) - expect(json_response.find { |e| e['id'] == developer.id }['access_level']).to eq(GroupMember::DEVELOPER) - expect(json_response.find { |e| e['id'] == master.id }['access_level']).to eq(GroupMember::MASTER) - expect(json_response.find { |e| e['id'] == guest.id }['access_level']).to eq(GroupMember::GUEST) - end - end - - it 'users not part of the group should get access error' do - get api("/groups/#{group_with_members.id}/members", stranger) - - expect(response).to have_http_status(404) - end - end - end - - describe "POST /groups/:id/members" do - context "when not a member of the group" do - it "should not add guest as member of group_no_members when adding being done by person outside the group" do - post api("/groups/#{group_no_members.id}/members", reporter), user_id: guest.id, access_level: GroupMember::MASTER - expect(response).to have_http_status(403) - end - end - - context "when a member of the group" do - it "should return ok and add new member" do - new_user = create(:user) - - expect do - post api("/groups/#{group_no_members.id}/members", owner), user_id: new_user.id, access_level: GroupMember::MASTER - end.to change { group_no_members.members.count }.by(1) - - expect(response).to have_http_status(201) - expect(json_response['name']).to eq(new_user.name) - expect(json_response['access_level']).to eq(GroupMember::MASTER) - end - - it "should not allow guest to modify group members" do - new_user = create(:user) - - expect do - post api("/groups/#{group_with_members.id}/members", guest), user_id: new_user.id, access_level: GroupMember::MASTER - end.not_to change { group_with_members.members.count } - - expect(response).to have_http_status(403) - end - - it "should return error if member already exists" do - post api("/groups/#{group_with_members.id}/members", owner), user_id: master.id, access_level: GroupMember::MASTER - expect(response).to have_http_status(409) - end - - it "should return a 400 error when user id is not given" do - post api("/groups/#{group_no_members.id}/members", owner), access_level: GroupMember::MASTER - expect(response).to have_http_status(400) - end - - it "should return a 400 error when access level is not given" do - post api("/groups/#{group_no_members.id}/members", owner), user_id: master.id - expect(response).to have_http_status(400) - end - - it "should return a 422 error when access level is not known" do - post api("/groups/#{group_no_members.id}/members", owner), user_id: master.id, access_level: 1234 - expect(response).to have_http_status(422) - end - end - end - - describe 'PUT /groups/:id/members/:user_id' do - context 'when not a member of the group' do - it 'should return a 409 error if the user is not a group member' do - put( - api("/groups/#{group_no_members.id}/members/#{developer.id}", - owner), access_level: GroupMember::MASTER - ) - expect(response).to have_http_status(404) - end - end - - context 'when a member of the group' do - it 'should return ok and update member access level' do - put( - api("/groups/#{group_with_members.id}/members/#{reporter.id}", - owner), - access_level: GroupMember::MASTER - ) - - expect(response).to have_http_status(200) - - get api("/groups/#{group_with_members.id}/members", owner) - json_reporter = json_response.find do |e| - e['id'] == reporter.id - end - - expect(json_reporter['access_level']).to eq(GroupMember::MASTER) - end - - it 'should not allow guest to modify group members' do - put( - api("/groups/#{group_with_members.id}/members/#{developer.id}", - guest), - access_level: GroupMember::MASTER - ) - - expect(response).to have_http_status(403) - - get api("/groups/#{group_with_members.id}/members", owner) - json_developer = json_response.find do |e| - e['id'] == developer.id - end - - expect(json_developer['access_level']).to eq(GroupMember::DEVELOPER) - end - - it 'should return a 400 error when access level is not given' do - put( - api("/groups/#{group_with_members.id}/members/#{master.id}", owner) - ) - expect(response).to have_http_status(400) - end - - it 'should return a 422 error when access level is not known' do - put( - api("/groups/#{group_with_members.id}/members/#{master.id}", owner), - access_level: 1234 - ) - expect(response).to have_http_status(422) - end - end - end - - describe 'DELETE /groups/:id/members/:user_id' do - context 'when not a member of the group' do - it "should not delete guest's membership of group_with_members" do - random_user = create(:user) - delete api("/groups/#{group_with_members.id}/members/#{owner.id}", random_user) - - expect(response).to have_http_status(404) - end - end - - context "when a member of the group" do - it "should delete guest's membership of group" do - expect do - delete api("/groups/#{group_with_members.id}/members/#{guest.id}", owner) - end.to change { group_with_members.members.count }.by(-1) - - expect(response).to have_http_status(200) - end - - it "should return a 404 error when user id is not known" do - delete api("/groups/#{group_with_members.id}/members/1328", owner) - expect(response).to have_http_status(404) - end - - it "should not allow guest to modify group members" do - delete api("/groups/#{group_with_members.id}/members/#{master.id}", guest) - expect(response).to have_http_status(403) - end - end - end -end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index c2c94040ece..3ba257256a0 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -21,14 +21,14 @@ describe API::API, api: true do describe "GET /groups" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/groups") expect(response).to have_http_status(401) end end context "when authenticated as user" do - it "normal user: should return an array of groups of user1" do + it "normal user: returns an array of groups of user1" do get api("/groups", user1) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -38,13 +38,23 @@ describe API::API, api: true do end context "when authenticated as admin" do - it "admin: should return an array of all groups" do + it "admin: returns an array of all groups" do get api("/groups", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(2) end end + + context "when using skip_groups in request" do + it "returns all groups excluding skipped groups" do + get api("/groups", admin), skip_groups: [group2.id] + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + end + end end describe "GET /groups/:id" do @@ -70,12 +80,12 @@ describe API::API, api: true do expect(json_response['shared_projects'][0]['id']).to eq(project.id) end - it "should not return a non existing group" do + it "does not return a non existing group" do get api("/groups/1328", user1) expect(response).to have_http_status(404) end - it "should not return a group not attached to user1" do + it "does not return a group not attached to user1" do get api("/groups/#{group2.id}", user1) expect(response).to have_http_status(404) @@ -83,31 +93,31 @@ describe API::API, api: true do end context "when authenticated as admin" do - it "should return any existing group" do + it "returns any existing group" do get api("/groups/#{group2.id}", admin) expect(response).to have_http_status(200) expect(json_response['name']).to eq(group2.name) end - it "should not return a non existing group" do + it "does not return a non existing group" do get api("/groups/1328", admin) expect(response).to have_http_status(404) end end context 'when using group path in URL' do - it 'should return any existing group' do + it 'returns any existing group' do get api("/groups/#{group1.path}", admin) expect(response).to have_http_status(200) expect(json_response['name']).to eq(group1.name) end - it 'should not return a non existing group' do + it 'does not return a non existing group' do get api('/groups/unknown', admin) expect(response).to have_http_status(404) end - it 'should not return a group not attached to user1' do + it 'does not return a group not attached to user1' do get api("/groups/#{group2.path}", user1) expect(response).to have_http_status(404) @@ -120,10 +130,11 @@ describe API::API, api: true do context 'when authenticated as the group owner' do it 'updates the group' do - put api("/groups/#{group1.id}", user1), name: new_group_name + put api("/groups/#{group1.id}", user1), name: new_group_name, request_access_enabled: true expect(response).to have_http_status(200) expect(json_response['name']).to eq(new_group_name) + expect(json_response['request_access_enabled']).to eq(true) end it 'returns 404 for a non existing group' do @@ -161,7 +172,7 @@ describe API::API, api: true do describe "GET /groups/:id/projects" do context "when authenticated as user" do - it "should return the group's projects" do + it "returns the group's projects" do get api("/groups/#{group1.id}/projects", user1) expect(response).to have_http_status(200) @@ -170,12 +181,12 @@ describe API::API, api: true do expect(project_names).to match_array([project1.name, project3.name]) end - it "should not return a non existing group" do + it "does not return a non existing group" do get api("/groups/1328/projects", user1) expect(response).to have_http_status(404) end - it "should not return a group not attached to user1" do + it "does not return a group not attached to user1" do get api("/groups/#{group2.id}/projects", user1) expect(response).to have_http_status(404) @@ -215,12 +226,12 @@ describe API::API, api: true do expect(project_names).to match_array([project1.name, project3.name]) end - it 'should not return a non existing group' do + it 'does not return a non existing group' do get api('/groups/unknown/projects', admin) expect(response).to have_http_status(404) end - it 'should not return a group not attached to user1' do + it 'does not return a group not attached to user1' do get api("/groups/#{group2.path}/projects", user1) expect(response).to have_http_status(404) @@ -230,30 +241,36 @@ describe API::API, api: true do describe "POST /groups" do context "when authenticated as user without group permissions" do - it "should not create group" do + it "does not create group" do post api("/groups", user1), attributes_for(:group) expect(response).to have_http_status(403) end end context "when authenticated as user with group permissions" do - it "should create group" do - post api("/groups", user3), attributes_for(:group) + it "creates group" do + group = attributes_for(:group, { request_access_enabled: false }) + + post api("/groups", user3), group expect(response).to have_http_status(201) + + expect(json_response["name"]).to eq(group[:name]) + expect(json_response["path"]).to eq(group[:path]) + expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled]) end - it "should not create group, duplicate" do + it "does not create group, duplicate" do post api("/groups", user3), { name: 'Duplicate Test', path: group2.path } expect(response).to have_http_status(400) expect(response.message).to eq("Bad Request") end - it "should return 400 bad request error if name not given" do + it "returns 400 bad request error if name not given" do post api("/groups", user3), { path: group2.path } expect(response).to have_http_status(400) end - it "should return 400 bad request error if path not given" do + it "returns 400 bad request error if path not given" do post api("/groups", user3), { name: 'test' } expect(response).to have_http_status(400) end @@ -262,24 +279,24 @@ describe API::API, api: true do describe "DELETE /groups/:id" do context "when authenticated as user" do - it "should remove group" do + it "removes group" do delete api("/groups/#{group1.id}", user1) expect(response).to have_http_status(200) end - it "should not remove a group if not an owner" do + it "does not remove a group if not an owner" do user4 = create(:user) group1.add_master(user4) delete api("/groups/#{group1.id}", user3) expect(response).to have_http_status(403) end - it "should not remove a non existing group" do + it "does not remove a non existing group" do delete api("/groups/1328", user1) expect(response).to have_http_status(404) end - it "should not remove a group not attached to user1" do + it "does not remove a group not attached to user1" do delete api("/groups/#{group2.id}", user1) expect(response).to have_http_status(404) @@ -287,12 +304,12 @@ describe API::API, api: true do end context "when authenticated as admin" do - it "should remove any existing group" do + it "removes any existing group" do delete api("/groups/#{group2.id}", admin) expect(response).to have_http_status(200) end - it "should not remove a non existing group" do + it "does not remove a non existing group" do delete api("/groups/1328", admin) expect(response).to have_http_status(404) end @@ -308,14 +325,14 @@ describe API::API, api: true do end context "when authenticated as user" do - it "should not transfer project to group" do + it "does not transfer project to group" do post api("/groups/#{group1.id}/projects/#{project.id}", user2) expect(response).to have_http_status(403) end end context "when authenticated as admin" do - it "should transfer project to group" do + it "transfers project to group" do post api("/groups/#{group1.id}/projects/#{project.id}", admin) expect(response).to have_http_status(201) end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index f6f85d6e95e..f0f590b0331 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -38,6 +38,105 @@ describe API::API, api: true do end end + describe 'GET /internal/two_factor_recovery_codes' do + it 'returns an error message when the key does not exist' do + post api('/internal/two_factor_recovery_codes'), + secret_token: secret_token, + key_id: 12345 + + expect(json_response['success']).to be_falsey + expect(json_response['message']).to eq('Could not find the given key') + end + + it 'returns an error message when the key is a deploy key' do + deploy_key = create(:deploy_key) + + post api('/internal/two_factor_recovery_codes'), + secret_token: secret_token, + key_id: deploy_key.id + + expect(json_response['success']).to be_falsey + expect(json_response['message']).to eq('Deploy keys cannot be used to retrieve recovery codes') + end + + it 'returns an error message when the user does not exist' do + key_without_user = create(:key, user: nil) + + post api('/internal/two_factor_recovery_codes'), + secret_token: secret_token, + key_id: key_without_user.id + + expect(json_response['success']).to be_falsey + expect(json_response['message']).to eq('Could not find a user for the given key') + expect(json_response['recovery_codes']).to be_nil + end + + context 'when two-factor is enabled' do + it 'returns new recovery codes when the user exists' do + allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(true) + allow_any_instance_of(User) + .to receive(:generate_otp_backup_codes!).and_return(%w(119135e5a3ebce8e 34bd7b74adbc8861)) + + post api('/internal/two_factor_recovery_codes'), + secret_token: secret_token, + key_id: key.id + + expect(json_response['success']).to be_truthy + expect(json_response['recovery_codes']).to match_array(%w(119135e5a3ebce8e 34bd7b74adbc8861)) + end + end + + context 'when two-factor is not enabled' do + it 'returns an error message' do + allow_any_instance_of(User).to receive(:two_factor_enabled?).and_return(false) + + post api('/internal/two_factor_recovery_codes'), + secret_token: secret_token, + key_id: key.id + + expect(json_response['success']).to be_falsey + expect(json_response['recovery_codes']).to be_nil + end + end + end + + describe "POST /internal/lfs_authenticate" do + before do + project.team << [user, :developer] + end + + context 'user key' do + it 'returns the correct information about the key' do + lfs_auth(key.id, project) + + expect(response).to have_http_status(200) + expect(json_response['username']).to eq(user.username) + expect(json_response['lfs_token']).to eq(Gitlab::LfsToken.new(key).token) + + expect(json_response['repository_http_path']).to eq(project.http_url_to_repo) + end + + it 'returns a 404 when the wrong key is provided' do + lfs_auth(nil, project) + + expect(response).to have_http_status(404) + end + end + + context 'deploy key' do + let(:key) { create(:deploy_key) } + + it 'returns the correct information about the key' do + lfs_auth(key.id, project) + + expect(response).to have_http_status(200) + expect(json_response['username']).to eq("lfs+deploy-key-#{key.id}") + expect(json_response['lfs_token']).to eq(Gitlab::LfsToken.new(key).token) + expect(json_response['repository_http_path']).to eq(project.http_url_to_repo) + end + end + end + describe "GET /internal/discover" do it do get(api("/internal/discover"), key_id: key.id, secret_token: secret_token) @@ -275,6 +374,24 @@ describe API::API, api: true do end end + describe 'GET /internal/merge_request_urls' do + let(:repo_name) { "#{project.namespace.name}/#{project.path}" } + let(:changes) { URI.escape("#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch") } + + before do + project.team << [user, :developer] + get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token + end + + it 'returns link to create new merge request' do + expect(json_response).to match [{ + "branch_name" => "new_branch", + "url" => "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", + "new_merge_request" => true + }] + end + end + def pull(key, project, protocol = 'ssh') post( api("/internal/allowed"), @@ -309,4 +426,13 @@ describe API::API, api: true do protocol: 'ssh' ) end + + def lfs_auth(key_id, project) + post( + api("/internal/lfs_authenticate"), + key_id: key_id, + secret_token: secret_token, + project: project.path_with_namespace + ) + end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 9d3d28e0b91..f840778ae9b 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe API::API, api: true do include ApiHelpers + let(:user) { create(:user) } let(:user2) { create(:user) } let(:non_member) { create(:user) } @@ -16,21 +17,27 @@ describe API::API, api: true do assignee: user, project: project, state: :closed, - milestone: milestone + milestone: milestone, + created_at: generate(:issue_created_at), + updated_at: 3.hours.ago end let!(:confidential_issue) do create :issue, :confidential, project: project, author: author, - assignee: assignee + assignee: assignee, + created_at: generate(:issue_created_at), + updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, assignee: user, project: project, - milestone: milestone + milestone: milestone, + created_at: generate(:issue_created_at), + updated_at: 1.hour.ago end let!(:label) do create(:label, title: 'label', color: '#FFAABB', project: project) @@ -49,28 +56,29 @@ describe API::API, api: true do describe "GET /issues" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/issues") expect(response).to have_http_status(401) end end context "when authenticated" do - it "should return an array of issues" do + it "returns an array of issues" do get api("/issues", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.first['title']).to eq(issue.title) + expect(json_response.last).to have_key('web_url') end - it "should add pagination headers and keep query params" do + it "adds pagination headers and keep query params" do get api("/issues?state=closed&per_page=3", user) expect(response.headers['Link']).to eq( '<http://www.example.com/api/v3/issues?page=1&per_page=3&private_token=%s&state=closed>; rel="first", <http://www.example.com/api/v3/issues?page=1&per_page=3&private_token=%s&state=closed>; rel="last"' % [user.private_token, user.private_token] ) end - it 'should return an array of closed issues' do + it 'returns an array of closed issues' do get api('/issues?state=closed', user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -78,7 +86,7 @@ describe API::API, api: true do expect(json_response.first['id']).to eq(closed_issue.id) end - it 'should return an array of opened issues' do + it 'returns an array of opened issues' do get api('/issues?state=opened', user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -86,7 +94,7 @@ describe API::API, api: true do expect(json_response.first['id']).to eq(issue.id) end - it 'should return an array of all issues' do + it 'returns an array of all issues' do get api('/issues?state=all', user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -95,7 +103,7 @@ describe API::API, api: true do expect(json_response.second['id']).to eq(closed_issue.id) end - it 'should return an array of labeled issues' do + it 'returns an array of labeled issues' do get api("/issues?labels=#{label.title}", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -103,7 +111,7 @@ describe API::API, api: true do expect(json_response.first['labels']).to eq([label.title]) end - it 'should return an array of labeled issues when at least one label matches' 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 @@ -111,14 +119,14 @@ describe API::API, api: true do expect(json_response.first['labels']).to eq([label.title]) end - it 'should return an empty array if no issue matches labels' do + it 'returns an empty array if no issue matches labels' do get api('/issues?labels=foo,bar', user) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(0) end - it 'should return an array of labeled issues matching given state' do + it 'returns an array of labeled issues matching given state' do get api("/issues?labels=#{label.title}&state=opened", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -127,12 +135,48 @@ describe API::API, api: true do expect(json_response.first['state']).to eq('opened') end - it 'should return an empty array if no issue matches labels and state filters' do + it 'returns an empty array if no issue matches labels and state filters' do get api("/issues?labels=#{label.title}&state=closed", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(0) end + + it 'sorts by created_at descending by default' do + get api('/issues', user) + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts ascending when requested' do + get api('/issues?sort=asc', user) + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at descending when requested' do + get api('/issues?order_by=updated_at', user) + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at ascending when requested' do + get api('/issues?order_by=updated_at&sort=asc', user) + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end end end @@ -145,21 +189,24 @@ describe API::API, api: true do assignee: user, project: group_project, state: :closed, - milestone: group_milestone + milestone: group_milestone, + updated_at: 3.hours.ago end let!(:group_confidential_issue) do create :issue, :confidential, project: group_project, author: author, - assignee: assignee + assignee: assignee, + updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, assignee: user, project: group_project, - milestone: group_milestone + milestone: group_milestone, + updated_at: 1.hour.ago end let!(:group_label) do create(:label, title: 'group_lbl', color: '#FFAABB', project: group_project) @@ -276,13 +323,49 @@ describe API::API, api: true do expect(json_response.length).to eq(1) expect(json_response.first['id']).to eq(group_closed_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'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts ascending when requested' do + get api("#{base_url}?sort=asc", user) + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at descending when requested' do + get api("#{base_url}?order_by=updated_at", user) + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at ascending when requested' do + get api("#{base_url}?order_by=updated_at&sort=asc", user) + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end end describe "GET /projects/:id/issues" do let(:base_url) { "/projects/#{project.id}" } let(:title) { milestone.title } - it 'should return project issues without confidential issues for non project members' do + it 'returns project issues without confidential issues for non project members' do get api("#{base_url}/issues", non_member) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -290,7 +373,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end - it 'should return project issues without confidential issues for project members with guest role' do + it 'returns project issues without confidential issues for project members with guest role' do get api("#{base_url}/issues", guest) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -298,7 +381,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end - it 'should return project confidential issues for author' do + it 'returns project confidential issues for author' do get api("#{base_url}/issues", author) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -306,7 +389,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end - it 'should return project confidential issues for assignee' do + it 'returns project confidential issues for assignee' do get api("#{base_url}/issues", assignee) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -314,7 +397,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end - it 'should return project issues with confidential issues for project members' do + it 'returns project issues with confidential issues for project members' do get api("#{base_url}/issues", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -322,7 +405,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end - it 'should return project confidential issues for admin' do + it 'returns project confidential issues for admin' do get api("#{base_url}/issues", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -330,7 +413,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end - it 'should return an array of labeled project issues' do + it 'returns an array of labeled project issues' do get api("#{base_url}/issues?labels=#{label.title}", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -338,7 +421,7 @@ describe API::API, api: true do expect(json_response.first['labels']).to eq([label.title]) end - it 'should return an array of labeled project issues when at least one label matches' do + it 'returns an array of labeled project issues when at least one label matches' 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 @@ -346,28 +429,28 @@ describe API::API, api: true do expect(json_response.first['labels']).to eq([label.title]) end - it 'should return an empty array if no project issue matches labels' do + it 'returns an empty array if no project issue matches labels' do get api("#{base_url}/issues?labels=foo,bar", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(0) end - it 'should return an empty array if no issue matches milestone' do + it 'returns an empty array if no issue matches milestone' do get api("#{base_url}/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 'should return an empty array if milestone does not exist' do + it 'returns an empty array if milestone does not exist' do get api("#{base_url}/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 'should return an array of issues in given milestone' do + it 'returns an array of issues in given milestone' do get api("#{base_url}/issues?milestone=#{title}", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -376,7 +459,7 @@ describe API::API, api: true do expect(json_response.second['id']).to eq(closed_issue.id) end - it 'should return an array of issues matching state in milestone' do + it 'returns an array of issues matching state in milestone' do get api("#{base_url}/issues?milestone=#{milestone.title}"\ '&state=closed', user) expect(response).to have_http_status(200) @@ -384,6 +467,42 @@ describe API::API, api: true do expect(json_response.length).to eq(1) expect(json_response.first['id']).to eq(closed_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'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts ascending when requested' do + get api("#{base_url}/issues?sort=asc", user) + response_dates = json_response.map { |issue| issue['created_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end + + it 'sorts by updated_at descending when requested' do + get api("#{base_url}/issues?order_by=updated_at", user) + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort.reverse) + end + + it 'sorts by updated_at ascending when requested' do + get api("#{base_url}/issues?order_by=updated_at&sort=asc", user) + response_dates = json_response.map { |issue| issue['updated_at'] } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_dates).to eq(response_dates.sort) + end end describe "GET /projects/:id/issues/:issue_id" do @@ -403,9 +522,10 @@ describe API::API, api: true do expect(json_response['milestone']).to be_a Hash expect(json_response['assignee']).to be_a Hash expect(json_response['author']).to be_a Hash + expect(json_response['confidential']).to be_falsy end - it "should return a project issue by id" do + it "returns a project issue by id" do get api("/projects/#{project.id}/issues/#{issue.id}", user) expect(response).to have_http_status(200) @@ -413,7 +533,7 @@ describe API::API, api: true do expect(json_response['iid']).to eq(issue.iid) end - it 'should return a project issue by iid' 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.first['title']).to eq issue.title @@ -421,44 +541,44 @@ describe API::API, api: true do expect(json_response.first['iid']).to eq issue.iid end - it "should return 404 if issue id not found" do + it "returns 404 if issue id not found" do get api("/projects/#{project.id}/issues/54321", user) expect(response).to have_http_status(404) end context 'confidential issues' do - it "should return 404 for non project members" do + it "returns 404 for non project members" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member) expect(response).to have_http_status(404) end - it "should return 404 for project members with guest role" do + it "returns 404 for project members with guest role" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest) expect(response).to have_http_status(404) end - it "should return confidential issue for project members" do + it "returns confidential issue for project members" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end - it "should return confidential issue for author" do + it "returns confidential issue for author" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", author) expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end - it "should return confidential issue for assignee" do + it "returns confidential issue for assignee" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee) expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end - it "should return confidential issue for admin" do + it "returns confidential issue for admin" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin) expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) @@ -468,21 +588,71 @@ describe API::API, api: true do end describe "POST /projects/:id/issues" do - it "should create a new project issue" do + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), title: 'new issue', labels: 'label, label2' + expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['confidential']).to be_falsy + end + + it 'creates a new confidential project issue' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: true + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_truthy + end + + it 'creates a new confidential project issue with a different param' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: 'y' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_truthy + end + + it 'creates a public issue when confidential param is false' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: false + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_falsy + end + + it 'creates a public issue when confidential param is invalid' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', confidential: 'foo' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_falsy end - it "should return a 400 bad request if title not given" do + it "sends notifications for subscribers of newly added labels" do + label = project.labels.first + label.toggle_subscription(user2) + + perform_enqueued_jobs do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', labels: label.title + end + + should_email(user2) + end + + it "returns a 400 bad request if title not given" do post api("/projects/#{project.id}/issues", user), labels: 'label, label2' expect(response).to have_http_status(400) end - it 'should allow special label names' do + it 'allows special label names' do post api("/projects/#{project.id}/issues", user), title: 'new issue', labels: 'label, label?, label&foo, ?, &' @@ -494,7 +664,7 @@ describe API::API, api: true do expect(json_response['labels']).to include '&' end - it 'should return 400 if title is too long' do + it 'returns 400 if title is too long' do post api("/projects/#{project.id}/issues", user), title: 'g' * 256 expect(response).to have_http_status(400) @@ -531,8 +701,8 @@ describe API::API, api: true do describe 'POST /projects/:id/issues with spam filtering' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) end let(:params) do @@ -543,7 +713,7 @@ describe API::API, api: true do } end - it "should not create a new project issue" do + it "does not create a new project issue" do expect { post api("/projects/#{project.id}/issues", user), params }.not_to change(Issue, :count) expect(response).to have_http_status(400) expect(json_response['message']).to eq({ "error" => "Spam detected" }) @@ -554,12 +724,11 @@ describe API::API, api: true do expect(spam_logs[0].description).to eq('content here') expect(spam_logs[0].user).to eq(user) expect(spam_logs[0].noteable_type).to eq('Issue') - expect(spam_logs[0].project_id).to eq(project.id) end end describe "PUT /projects/:id/issues/:issue_id to update only title" do - it "should update a project issue" do + it "updates a project issue" do put api("/projects/#{project.id}/issues/#{issue.id}", user), title: 'updated title' expect(response).to have_http_status(200) @@ -567,13 +736,13 @@ describe API::API, api: true do expect(json_response['title']).to eq('updated title') end - it "should return 404 error if issue id not found" do + it "returns 404 error if issue id not found" do put api("/projects/#{project.id}/issues/44444", user), title: 'updated title' expect(response).to have_http_status(404) end - it 'should allow special label names' do + it 'allows special label names' do put api("/projects/#{project.id}/issues/#{issue.id}", user), title: 'updated title', labels: 'label, label?, label&foo, ?, &' @@ -587,38 +756,62 @@ describe API::API, api: true do end context 'confidential issues' do - it "should return 403 for non project members" do + it "returns 403 for non project members" do put api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member), title: 'updated title' expect(response).to have_http_status(403) end - it "should return 403 for project members with guest role" do + it "returns 403 for project members with guest role" do put api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest), title: 'updated title' expect(response).to have_http_status(403) end - it "should update a confidential issue for project members" do + it "updates a confidential issue for project members" do put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end - it "should update a confidential issue for author" do + it "updates a confidential issue for author" do put api("/projects/#{project.id}/issues/#{confidential_issue.id}", author), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end - it "should update a confidential issue for admin" do + it "updates a confidential issue for admin" do put api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end + + it 'sets an issue to confidential' do + put api("/projects/#{project.id}/issues/#{issue.id}", user), + confidential: true + + expect(response).to have_http_status(200) + expect(json_response['confidential']).to be_truthy + end + + it 'makes a confidential issue public' do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + confidential: false + + expect(response).to have_http_status(200) + expect(json_response['confidential']).to be_falsy + end + + it 'does not update a confidential issue with wrong confidential flag' do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + confidential: 'foo' + + expect(response).to have_http_status(200) + expect(json_response['confidential']).to be_truthy + end end end @@ -626,21 +819,33 @@ describe API::API, api: true do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } - it 'should not update labels if not present' do + it 'does not update labels if not present' do put api("/projects/#{project.id}/issues/#{issue.id}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['labels']).to eq([label.title]) end - it 'should remove all labels' do + it "sends notifications for subscribers of newly added labels when issue is updated" do + label = create(:label, title: 'foo', color: '#FFAABB', project: project) + label.toggle_subscription(user2) + + perform_enqueued_jobs do + put api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title', labels: label.title + end + + should_email(user2) + end + + it 'removes all labels' do put api("/projects/#{project.id}/issues/#{issue.id}", user), labels: '' expect(response).to have_http_status(200) expect(json_response['labels']).to eq([]) end - it 'should update labels' do + it 'updates labels' do put api("/projects/#{project.id}/issues/#{issue.id}", user), labels: 'foo,bar' expect(response).to have_http_status(200) @@ -648,7 +853,7 @@ describe API::API, api: true do expect(json_response['labels']).to include 'bar' end - it 'should allow special label names' do + it 'allows special label names' do put api("/projects/#{project.id}/issues/#{issue.id}", user), labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' expect(response.status).to eq(200) @@ -662,7 +867,7 @@ describe API::API, api: true do expect(json_response['labels']).to include '&' end - it 'should return 400 if title is too long' do + it 'returns 400 if title is too long' do put api("/projects/#{project.id}/issues/#{issue.id}", user), title: 'g' * 256 expect(response).to have_http_status(400) @@ -673,7 +878,7 @@ describe API::API, api: true do end describe "PUT /projects/:id/issues/:issue_id to update state and label" do - it "should update a project issue" do + it "updates a project issue" do put api("/projects/#{project.id}/issues/#{issue.id}", user), labels: 'label2', state_event: "close" expect(response).to have_http_status(200) diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb index 1861882d59e..893ed5c2b10 100644 --- a/spec/requests/api/keys_spec.rb +++ b/spec/requests/api/keys_spec.rb @@ -12,20 +12,20 @@ describe API::API, api: true do before { admin } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api("/keys/#{key.id}") expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should return 404 for non-existing key' do + it 'returns 404 for non-existing key' do get api('/keys/999999', admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Not found') end - it 'should return single ssh key with user information' do + it 'returns single ssh key with user information' do user.keys << key user.save get api("/keys/#{key.id}", admin) diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb index 63636b4a1b6..83789223019 100644 --- a/spec/requests/api/labels_spec.rb +++ b/spec/requests/api/labels_spec.rb @@ -12,7 +12,7 @@ describe API::API, api: true do end describe 'GET /projects/:id/labels' do - it 'should return project labels' do + it 'returns project labels' do get api("/projects/#{project.id}/labels", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -22,7 +22,7 @@ describe API::API, api: true do end describe 'POST /projects/:id/labels' do - it 'should return created label when all params' do + it 'returns created label when all params' do post api("/projects/#{project.id}/labels", user), name: 'Foo', color: '#FFAABB', @@ -33,7 +33,7 @@ describe API::API, api: true do expect(json_response['description']).to eq('test') end - it 'should return created label when only required params' do + it 'returns created label when only required params' do post api("/projects/#{project.id}/labels", user), name: 'Foo & Bar', color: '#FFAABB' @@ -43,17 +43,17 @@ describe API::API, api: true do expect(json_response['description']).to be_nil end - it 'should return a 400 bad request if name not given' do + it 'returns a 400 bad request if name not given' do post api("/projects/#{project.id}/labels", user), color: '#FFAABB' expect(response).to have_http_status(400) end - it 'should return a 400 bad request if color not given' do + it 'returns a 400 bad request if color not given' do post api("/projects/#{project.id}/labels", user), name: 'Foobar' expect(response).to have_http_status(400) end - it 'should return 400 for invalid color' do + it 'returns 400 for invalid color' do post api("/projects/#{project.id}/labels", user), name: 'Foo', color: '#FFAA' @@ -61,7 +61,7 @@ describe API::API, api: true do expect(json_response['message']['color']).to eq(['must be a valid color code']) end - it 'should return 400 for too long color code' do + it 'returns 400 for too long color code' do post api("/projects/#{project.id}/labels", user), name: 'Foo', color: '#FFAAFFFF' @@ -69,7 +69,7 @@ describe API::API, api: true do expect(json_response['message']['color']).to eq(['must be a valid color code']) end - it 'should return 400 for invalid name' do + it 'returns 400 for invalid name' do post api("/projects/#{project.id}/labels", user), name: ',', color: '#FFAABB' @@ -77,7 +77,7 @@ describe API::API, api: true do expect(json_response['message']['title']).to eq(['is invalid']) end - it 'should return 409 if label already exists' do + it 'returns 409 if label already exists' do post api("/projects/#{project.id}/labels", user), name: 'label1', color: '#FFAABB' @@ -87,25 +87,25 @@ describe API::API, api: true do end describe 'DELETE /projects/:id/labels' do - it 'should return 200 for existing label' do + it 'returns 200 for existing label' do delete api("/projects/#{project.id}/labels", user), name: 'label1' expect(response).to have_http_status(200) end - it 'should return 404 for non existing label' do + it 'returns 404 for non existing label' do delete api("/projects/#{project.id}/labels", user), name: 'label2' expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Label Not Found') end - it 'should return 400 for wrong parameters' do + it 'returns 400 for wrong parameters' do delete api("/projects/#{project.id}/labels", user) expect(response).to have_http_status(400) end end describe 'PUT /projects/:id/labels' do - it 'should return 200 if name and colors and description are changed' do + it 'returns 200 if name and colors and description are changed' do put api("/projects/#{project.id}/labels", user), name: 'label1', new_name: 'New Label', @@ -117,7 +117,7 @@ describe API::API, api: true do expect(json_response['description']).to eq('test') end - it 'should return 200 if name is changed' do + it 'returns 200 if name is changed' do put api("/projects/#{project.id}/labels", user), name: 'label1', new_name: 'New Label' @@ -126,7 +126,7 @@ describe API::API, api: true do expect(json_response['color']).to eq(label1.color) end - it 'should return 200 if colors is changed' do + it 'returns 200 if colors is changed' do put api("/projects/#{project.id}/labels", user), name: 'label1', color: '#FFFFFF' @@ -135,7 +135,7 @@ describe API::API, api: true do expect(json_response['color']).to eq('#FFFFFF') end - it 'should return 200 if description is changed' do + it 'returns 200 if description is changed' do put api("/projects/#{project.id}/labels", user), name: 'label1', description: 'test' @@ -144,27 +144,27 @@ describe API::API, api: true do expect(json_response['description']).to eq('test') end - it 'should return 404 if label does not exist' do + it 'returns 404 if label does not exist' do put api("/projects/#{project.id}/labels", user), name: 'label2', new_name: 'label3' expect(response).to have_http_status(404) end - it 'should return 400 if no label name given' do + it 'returns 400 if no label name given' do put api("/projects/#{project.id}/labels", user), new_name: 'label2' expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "name" not given') end - it 'should return 400 if no new parameters given' do + it 'returns 400 if no new parameters given' do put api("/projects/#{project.id}/labels", user), name: 'label1' expect(response).to have_http_status(400) expect(json_response['message']).to eq('Required parameters '\ '"new_name" or "color" missing') end - it 'should return 400 for invalid name' do + it 'returns 400 for invalid name' do put api("/projects/#{project.id}/labels", user), name: 'label1', new_name: ',', @@ -173,7 +173,7 @@ describe API::API, api: true do expect(json_response['message']['title']).to eq(['is invalid']) end - it 'should return 400 when color code is too short' do + it 'returns 400 when color code is too short' do put api("/projects/#{project.id}/labels", user), name: 'label1', color: '#FF' @@ -181,7 +181,7 @@ describe API::API, api: true do expect(json_response['message']['color']).to eq(['must be a valid color code']) end - it 'should return 400 for too long color code' do + it 'returns 400 for too long color code' do post api("/projects/#{project.id}/labels", user), name: 'Foo', color: '#FFAAFFFF' @@ -192,7 +192,7 @@ describe API::API, api: true do describe "POST /projects/:id/labels/:label_id/subscription" do context "when label_id is a label title" do - it "should subscribe to the label" do + it "subscribes to the label" do post api("/projects/#{project.id}/labels/#{label1.title}/subscription", user) expect(response).to have_http_status(201) @@ -202,7 +202,7 @@ describe API::API, api: true do end context "when label_id is a label ID" do - it "should subscribe to the label" do + it "subscribes to the label" do post api("/projects/#{project.id}/labels/#{label1.id}/subscription", user) expect(response).to have_http_status(201) @@ -214,7 +214,7 @@ describe API::API, api: true do context "when user is already subscribed to label" do before { label1.subscribe(user) } - it "should return 304" do + it "returns 304" do post api("/projects/#{project.id}/labels/#{label1.id}/subscription", user) expect(response).to have_http_status(304) @@ -222,7 +222,7 @@ describe API::API, api: true do end context "when label ID is not found" do - it "should a return 404 error" do + it "returns 404 error" do post api("/projects/#{project.id}/labels/1234/subscription", user) expect(response).to have_http_status(404) @@ -234,7 +234,7 @@ describe API::API, api: true do before { label1.subscribe(user) } context "when label_id is a label title" do - it "should unsubscribe from the label" do + it "unsubscribes from the label" do delete api("/projects/#{project.id}/labels/#{label1.title}/subscription", user) expect(response).to have_http_status(200) @@ -244,7 +244,7 @@ describe API::API, api: true do end context "when label_id is a label ID" do - it "should unsubscribe from the label" do + it "unsubscribes from the label" do delete api("/projects/#{project.id}/labels/#{label1.id}/subscription", user) expect(response).to have_http_status(200) @@ -256,7 +256,7 @@ describe API::API, api: true do context "when user is already unsubscribed from label" do before { label1.unsubscribe(user) } - it "should return 304" do + it "returns 304" do delete api("/projects/#{project.id}/labels/#{label1.id}/subscription", user) expect(response).to have_http_status(304) @@ -264,7 +264,7 @@ describe API::API, api: true do end context "when label ID is not found" do - it "should a return 404 error" do + it "returns 404 error" do delete api("/projects/#{project.id}/labels/1234/subscription", user) expect(response).to have_http_status(404) diff --git a/spec/requests/api/license_templates_spec.rb b/spec/requests/api/license_templates_spec.rb deleted file mode 100644 index 9a1894d63a2..00000000000 --- a/spec/requests/api/license_templates_spec.rb +++ /dev/null @@ -1,136 +0,0 @@ -require 'spec_helper' - -describe API::API, api: true do - include ApiHelpers - - describe 'Entity' do - before { get api('/licenses/mit') } - - it { expect(json_response['key']).to eq('mit') } - it { expect(json_response['name']).to eq('MIT License') } - it { expect(json_response['nickname']).to be_nil } - it { expect(json_response['popular']).to be true } - it { expect(json_response['html_url']).to eq('http://choosealicense.com/licenses/mit/') } - it { expect(json_response['source_url']).to eq('https://opensource.org/licenses/MIT') } - it { expect(json_response['description']).to include('A permissive license that is short and to the point.') } - it { expect(json_response['conditions']).to eq(%w[include-copyright]) } - it { expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) } - it { expect(json_response['limitations']).to eq(%w[no-liability]) } - it { expect(json_response['content']).to include('The MIT License (MIT)') } - end - - describe 'GET /licenses' do - it 'returns a list of available license templates' do - get api('/licenses') - - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.size).to eq(15) - expect(json_response.map { |l| l['key'] }).to include('agpl-3.0') - end - - describe 'the popular parameter' do - context 'with popular=1' do - it 'returns a list of available popular license templates' do - get api('/licenses?popular=1') - - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.size).to eq(3) - expect(json_response.map { |l| l['key'] }).to include('apache-2.0') - end - end - end - end - - describe 'GET /licenses/:key' do - context 'with :project and :fullname given' do - before do - get api("/licenses/#{license_type}?project=My+Awesome+Project&fullname=Anton+#{license_type.upcase}") - end - - context 'for the mit license' do - let(:license_type) { 'mit' } - - it 'returns the license text' do - expect(json_response['content']).to include('The MIT License (MIT)') - end - - it 'replaces placeholder values' do - expect(json_response['content']).to include("Copyright (c) #{Time.now.year} Anton") - end - end - - context 'for the agpl-3.0 license' do - let(:license_type) { 'agpl-3.0' } - - it 'returns the license text' do - expect(json_response['content']).to include('GNU AFFERO GENERAL PUBLIC LICENSE') - end - - it 'replaces placeholder values' do - expect(json_response['content']).to include('My Awesome Project') - expect(json_response['content']).to include("Copyright (C) #{Time.now.year} Anton") - end - end - - context 'for the gpl-3.0 license' do - let(:license_type) { 'gpl-3.0' } - - it 'returns the license text' do - expect(json_response['content']).to include('GNU GENERAL PUBLIC LICENSE') - end - - it 'replaces placeholder values' do - expect(json_response['content']).to include('My Awesome Project') - expect(json_response['content']).to include("Copyright (C) #{Time.now.year} Anton") - end - end - - context 'for the gpl-2.0 license' do - let(:license_type) { 'gpl-2.0' } - - it 'returns the license text' do - expect(json_response['content']).to include('GNU GENERAL PUBLIC LICENSE') - end - - it 'replaces placeholder values' do - expect(json_response['content']).to include('My Awesome Project') - expect(json_response['content']).to include("Copyright (C) #{Time.now.year} Anton") - end - end - - context 'for the apache-2.0 license' do - let(:license_type) { 'apache-2.0' } - - it 'returns the license text' do - expect(json_response['content']).to include('Apache License') - end - - it 'replaces placeholder values' do - expect(json_response['content']).to include("Copyright #{Time.now.year} Anton") - end - end - - context 'for an uknown license' do - let(:license_type) { 'muth-over9000' } - - it 'returns a 404' do - expect(response).to have_http_status(404) - end - end - end - - context 'with no :fullname given' do - context 'with an authenticated user' do - let(:user) { create(:user) } - - it 'replaces the copyright owner placeholder with the name of the current user' do - get api('/licenses/mit', user) - - expect(json_response['content']).to include("Copyright (c) #{Time.now.year} #{user.name}") - end - end - end - end -end diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb new file mode 100644 index 00000000000..391fc13a380 --- /dev/null +++ b/spec/requests/api/lint_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe API::Lint, api: true do + include ApiHelpers + + describe 'POST /ci/lint' do + context 'with valid .gitlab-ci.yaml content' do + let(:yaml_content) do + File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + end + + it 'passes validation' do + post api('/ci/lint'), { content: yaml_content } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Hash + expect(json_response['status']).to eq('valid') + expect(json_response['errors']).to eq([]) + end + end + + context 'with an invalid .gitlab_ci.yml' do + it 'responds with errors about invalid syntax' do + post api('/ci/lint'), { content: 'invalid content' } + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq('invalid') + expect(json_response['errors']).to eq(['Invalid configuration format']) + end + + it "responds with errors about invalid configuration" do + post api('/ci/lint'), { content: '{ image: "ruby:2.1", services: ["postgres"] }' } + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq('invalid') + expect(json_response['errors']).to eq(['jobs config should contain at least one visible job']) + end + end + + context 'without the content parameter' do + it 'responds with validation error about missing content' do + post api('/ci/lint') + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('content is missing') + end + end + end +end diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb new file mode 100644 index 00000000000..d22e0595788 --- /dev/null +++ b/spec/requests/api/members_spec.rb @@ -0,0 +1,331 @@ +require 'spec_helper' + +describe API::Members, api: true do + include ApiHelpers + + let(:master) { create(:user) } + let(:developer) { create(:user) } + let(:access_requester) { create(:user) } + let(:stranger) { create(:user) } + + let(:project) do + project = create(:project, :public, creator_id: master.id, namespace: master.namespace) + project.team << [developer, :developer] + project.team << [master, :master] + project.request_access(access_requester) + project + end + + let!(:group) do + group = create(:group, :public) + group.add_developer(developer) + group.add_owner(master) + group.request_access(access_requester) + group + end + + shared_examples 'GET /:sources/:id/members' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/members", stranger) } + end + + %i[master developer access_requester stranger].each do |type| + context "when authenticated as a #{type}" do + it 'returns 200' do + user = public_send(type) + get api("/#{source_type.pluralize}/#{source.id}/members", user) + + expect(response).to have_http_status(200) + expect(json_response.size).to eq(2) + expect(json_response.map { |u| u['id'] }).to match_array [master.id, developer.id] + end + end + end + + it 'does not return invitees' do + create(:"#{source_type}_member", invite_token: '123', invite_email: 'test@abc.com', source: source, user: nil) + + get api("/#{source_type.pluralize}/#{source.id}/members", developer) + + expect(response).to have_http_status(200) + expect(json_response.size).to eq(2) + expect(json_response.map { |u| u['id'] }).to match_array [master.id, developer.id] + end + + it 'finds members with query string' do + get api("/#{source_type.pluralize}/#{source.id}/members", developer), query: master.username + + expect(response).to have_http_status(200) + expect(json_response.count).to eq(1) + expect(json_response.first['username']).to eq(master.username) + end + end + end + + shared_examples 'GET /:sources/:id/members/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger) } + end + + context 'when authenticated as a non-member' do + %i[access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 200' do + user = public_send(type) + get api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user) + + expect(response).to have_http_status(200) + # User attributes + expect(json_response['id']).to eq(developer.id) + expect(json_response['name']).to eq(developer.name) + expect(json_response['username']).to eq(developer.username) + expect(json_response['state']).to eq(developer.state) + expect(json_response['avatar_url']).to eq(developer.avatar_url) + expect(json_response['web_url']).to eq(Gitlab::Routing.url_helpers.user_url(developer)) + + # Member attributes + expect(json_response['access_level']).to eq(Member::DEVELOPER) + end + end + end + end + end + end + + shared_examples 'POST /:sources/:id/members' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) do + post api("/#{source_type.pluralize}/#{source.id}/members", stranger), + user_id: access_requester.id, access_level: Member::MASTER + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + post api("/#{source_type.pluralize}/#{source.id}/members", user), + user_id: access_requester.id, access_level: Member::MASTER + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + context 'and new member is already a requester' do + it 'transforms the requester into a proper member' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: access_requester.id, access_level: Member::MASTER + + expect(response).to have_http_status(201) + end.to change { source.members.count }.by(1) + expect(source.requesters.count).to eq(0) + expect(json_response['id']).to eq(access_requester.id) + expect(json_response['access_level']).to eq(Member::MASTER) + end + end + + it 'creates a new member' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: stranger.id, access_level: Member::DEVELOPER, expires_at: '2016-08-05' + + expect(response).to have_http_status(201) + end.to change { source.members.count }.by(1) + expect(json_response['id']).to eq(stranger.id) + expect(json_response['access_level']).to eq(Member::DEVELOPER) + expect(json_response['expires_at']).to eq('2016-08-05') + end + end + + it "returns #{source_type == 'project' ? 201 : 409} if member already exists" do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: master.id, access_level: Member::MASTER + + expect(response).to have_http_status(source_type == 'project' ? 201 : 409) + end + + it 'returns 400 when user_id is not given' do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + access_level: Member::MASTER + + expect(response).to have_http_status(400) + end + + it 'returns 400 when access_level is not given' do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: stranger.id + + expect(response).to have_http_status(400) + end + + it 'returns 422 when access_level is not valid' do + post api("/#{source_type.pluralize}/#{source.id}/members", master), + user_id: stranger.id, access_level: 1234 + + expect(response).to have_http_status(422) + end + end + end + + shared_examples 'PUT /:sources/:id/members/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger), + access_level: Member::MASTER + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user), + access_level: Member::MASTER + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'updates the member' do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master), + access_level: Member::MASTER, expires_at: '2016-08-05' + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(developer.id) + expect(json_response['access_level']).to eq(Member::MASTER) + expect(json_response['expires_at']).to eq('2016-08-05') + end + end + + it 'returns 409 if member does not exist' do + put api("/#{source_type.pluralize}/#{source.id}/members/123", master), + access_level: Member::MASTER + + expect(response).to have_http_status(404) + end + + it 'returns 400 when access_level is not given' do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master) + + expect(response).to have_http_status(400) + end + + it 'returns 422 when access level is not valid' do + put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master), + access_level: 1234 + + expect(response).to have_http_status(422) + end + end + end + + shared_examples 'DELETE /:sources/:id/members/:user_id' do |source_type| + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", stranger) } + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", user) + + expect(response).to have_http_status(403) + end + end + end + end + + context 'when authenticated as a member and deleting themself' do + it 'deletes the member' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", developer) + + expect(response).to have_http_status(200) + end.to change { source.members.count }.by(-1) + end + end + + context 'when authenticated as a master/owner' do + context 'and member is a requester' do + it "returns #{source_type == 'project' ? 200 : 404}" do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/members/#{access_requester.id}", master) + + expect(response).to have_http_status(source_type == 'project' ? 200 : 404) + end.not_to change { source.requesters.count } + end + end + + it 'deletes the member' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master) + + expect(response).to have_http_status(200) + end.to change { source.members.count }.by(-1) + end + end + + it "returns #{source_type == 'project' ? 200 : 404} if member does not exist" do + delete api("/#{source_type.pluralize}/#{source.id}/members/123", master) + + expect(response).to have_http_status(source_type == 'project' ? 200 : 404) + end + end + end + + it_behaves_like 'GET /:sources/:id/members', 'project' do + let(:source) { project } + end + + it_behaves_like 'GET /:sources/:id/members', 'group' do + let(:source) { group } + end + + it_behaves_like 'GET /:sources/:id/members/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'GET /:sources/:id/members/:user_id', 'group' do + let(:source) { group } + end + + it_behaves_like 'POST /:sources/:id/members', 'project' do + let(:source) { project } + end + + it_behaves_like 'POST /:sources/:id/members', 'group' do + let(:source) { group } + end + + it_behaves_like 'PUT /:sources/:id/members/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'PUT /:sources/:id/members/:user_id', 'group' do + let(:source) { group } + end + + it_behaves_like 'DELETE /:sources/:id/members/:user_id', 'project' do + let(:source) { project } + end + + it_behaves_like 'DELETE /:sources/:id/members/:user_id', 'group' do + let(:source) { group } + end +end diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb new file mode 100644 index 00000000000..8f1e5ac9891 --- /dev/null +++ b/spec/requests/api/merge_request_diffs_spec.rb @@ -0,0 +1,49 @@ +require "spec_helper" + +describe API::API, 'MergeRequestDiffs', api: true do + include ApiHelpers + + let!(:user) { create(:user) } + let!(:merge_request) { create(:merge_request, importing: true) } + let!(:project) { merge_request.target_project } + + before do + merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') + merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') + project.team << [user, :master] + end + + describe 'GET /projects/:id/merge_requests/:merge_request_id/versions' do + context 'valid merge request' do + before { get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) } + let(:merge_request_diff) { merge_request.merge_request_diffs.first } + + it { expect(response.status).to eq 200 } + it { expect(json_response.size).to eq(merge_request.merge_request_diffs.size) } + it { expect(json_response.first['id']).to eq(merge_request_diff.id) } + it { expect(json_response.first['head_commit_sha']).to eq(merge_request_diff.head_commit_sha) } + end + + it 'returns a 404 when merge_request_id not found' do + get api("/projects/#{project.id}/merge_requests/999/versions", user) + expect(response).to have_http_status(404) + end + end + + describe 'GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id' do + context 'valid merge request' do + before { get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) } + let(:merge_request_diff) { merge_request.merge_request_diffs.first } + + it { expect(response.status).to eq 200 } + it { expect(json_response['id']).to eq(merge_request_diff.id) } + it { expect(json_response['head_commit_sha']).to eq(merge_request_diff.head_commit_sha) } + it { expect(json_response['diffs'].size).to eq(merge_request_diff.diffs.size) } + end + + it 'returns a 404 when merge_request_id not found' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/999", user) + expect(response).to have_http_status(404) + end + end +end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 4ea8c7b84d3..362db41632a 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -9,33 +9,41 @@ describe API::API, api: true do let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time) } let!(:merge_request_closed) { create(:merge_request, state: "closed", author: user, assignee: user, source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) } - let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds) } + let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') } let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") } let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") } let(:milestone) { create(:milestone, title: '1.0.0', project: project) } before do - project.team << [user, :reporters] + project.team << [user, :reporter] end describe "GET /projects/:id/merge_requests" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/projects/#{project.id}/merge_requests") expect(response).to have_http_status(401) end end context "when authenticated" do - it "should return an array of all merge_requests" do + it "returns an array of all merge_requests" do get api("/projects/#{project.id}/merge_requests", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(3) expect(json_response.last['title']).to eq(merge_request.title) + expect(json_response.last).to have_key('web_url') + expect(json_response.last['sha']).to eq(merge_request.diff_head_sha) + expect(json_response.last['merge_commit_sha']).to be_nil + expect(json_response.last['merge_commit_sha']).to eq(merge_request.merge_commit_sha) + expect(json_response.first['title']).to eq(merge_request_merged.title) + expect(json_response.first['sha']).to eq(merge_request_merged.diff_head_sha) + expect(json_response.first['merge_commit_sha']).not_to be_nil + expect(json_response.first['merge_commit_sha']).to eq(merge_request_merged.merge_commit_sha) end - it "should return an array of all merge_requests" do + it "returns an array of all merge_requests" do get api("/projects/#{project.id}/merge_requests?state", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -43,7 +51,7 @@ describe API::API, api: true do expect(json_response.last['title']).to eq(merge_request.title) end - it "should return an array of open merge_requests" do + it "returns an array of open merge_requests" do get api("/projects/#{project.id}/merge_requests?state=opened", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -51,7 +59,7 @@ describe API::API, api: true do expect(json_response.last['title']).to eq(merge_request.title) end - it "should return an array of closed merge_requests" do + it "returns an array of closed merge_requests" do get api("/projects/#{project.id}/merge_requests?state=closed", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -59,7 +67,7 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(merge_request_closed.title) end - it "should return an array of merged merge_requests" do + it "returns an array of merged merge_requests" do get api("/projects/#{project.id}/merge_requests?state=merged", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -73,7 +81,7 @@ describe API::API, api: true do @mr_earlier = mr_with_earlier_created_and_updated_at_time end - it "should return an array of merge_requests in ascending order" do + it "returns an array of merge_requests in ascending order" do get api("/projects/#{project.id}/merge_requests?sort=asc", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -82,7 +90,7 @@ describe API::API, api: true do expect(response_dates).to eq(response_dates.sort) end - it "should return an array of merge_requests in descending order" do + it "returns an array of merge_requests in descending order" do get api("/projects/#{project.id}/merge_requests?sort=desc", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -91,7 +99,7 @@ describe API::API, api: true do expect(response_dates).to eq(response_dates.sort.reverse) end - it "should return an array of merge_requests ordered by updated_at" do + it "returns an array of merge_requests ordered by updated_at" do get api("/projects/#{project.id}/merge_requests?order_by=updated_at", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -100,7 +108,7 @@ describe API::API, api: true do expect(response_dates).to eq(response_dates.sort.reverse) end - it "should return an array of merge_requests ordered by created_at" do + it "returns an array of merge_requests ordered by created_at" do get api("/projects/#{project.id}/merge_requests?order_by=created_at&sort=asc", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -142,7 +150,7 @@ describe API::API, api: true do expect(json_response['force_close_merge_request']).to be_falsy end - it "should return merge_request" do + it "returns merge_request" do get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(merge_request.title) @@ -153,7 +161,7 @@ describe API::API, api: true do expect(json_response['force_close_merge_request']).to be_falsy end - it 'should return merge_request by iid' do + it 'returns merge_request by iid' do url = "/projects/#{project.id}/merge_requests?iid=#{merge_request.iid}" get api(url, user) expect(response.status).to eq 200 @@ -161,7 +169,7 @@ describe API::API, api: true do expect(json_response.first['id']).to eq merge_request.id end - it "should return a 404 error if merge_request_id not found" do + it "returns a 404 error if merge_request_id not found" do get api("/projects/#{project.id}/merge_requests/999", user) expect(response).to have_http_status(404) end @@ -169,7 +177,7 @@ describe API::API, api: true do context 'Work in Progress' do let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) } - it "should return merge_request" do + it "returns merge_request" do get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.id}", user) expect(response).to have_http_status(200) expect(json_response['work_in_progress']).to eq(true) @@ -195,7 +203,7 @@ describe API::API, api: true do end describe 'GET /projects/:id/merge_requests/:merge_request_id/changes' do - it 'should return the change information of the merge_request' do + it 'returns the change information of the merge_request' do get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user) expect(response.status).to eq 200 expect(json_response['changes'].size).to eq(merge_request.diffs.size) @@ -209,7 +217,7 @@ describe API::API, api: true do describe "POST /projects/:id/merge_requests" do context 'between branches projects' do - it "should return merge_request" do + it "returns merge_request" do post api("/projects/#{project.id}/merge_requests", user), title: 'Test merge_request', source_branch: 'feature_conflict', @@ -226,31 +234,31 @@ describe API::API, api: true do expect(json_response['should_remove_source_branch?']).to be false end - it "should return 422 when source_branch equals target_branch" do + it "returns 422 when source_branch equals target_branch" do post api("/projects/#{project.id}/merge_requests", user), title: "Test merge_request", source_branch: "master", target_branch: "master", author: user expect(response).to have_http_status(422) end - it "should return 400 when source_branch is missing" do + it "returns 400 when source_branch is missing" do post api("/projects/#{project.id}/merge_requests", user), title: "Test merge_request", target_branch: "master", author: user expect(response).to have_http_status(400) end - it "should return 400 when target_branch is missing" do + it "returns 400 when target_branch is missing" do post api("/projects/#{project.id}/merge_requests", user), title: "Test merge_request", source_branch: "markdown", author: user expect(response).to have_http_status(400) end - it "should return 400 when title is missing" do + it "returns 400 when title is missing" do post api("/projects/#{project.id}/merge_requests", user), target_branch: 'master', source_branch: 'markdown' expect(response).to have_http_status(400) end - it 'should allow special label names' do + it 'allows special label names' do post api("/projects/#{project.id}/merge_requests", user), title: 'Test merge_request', source_branch: 'markdown', @@ -275,7 +283,7 @@ describe API::API, api: true do @mr = MergeRequest.all.last end - it 'should return 409 when MR already exists for source/target' do + it 'returns 409 when MR already exists for source/target' do expect do post api("/projects/#{project.id}/merge_requests", user), title: 'New test merge_request', @@ -294,10 +302,10 @@ describe API::API, api: true do let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) } before :each do |each| - fork_project.team << [user2, :reporters] + fork_project.team << [user2, :reporter] end - it "should return merge_request" do + it "returns merge_request" do post api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master", author: user2, target_project_id: project.id, description: 'Test description for Test merge_request' @@ -306,7 +314,7 @@ describe API::API, api: true do expect(json_response['description']).to eq('Test description for Test merge_request') end - it "should not return 422 when source_branch equals target_branch" do + it "does not return 422 when source_branch equals target_branch" do expect(project.id).not_to eq(fork_project.id) expect(fork_project.forked?).to be_truthy expect(fork_project.forked_from_project).to eq(project) @@ -316,26 +324,26 @@ describe API::API, api: true do expect(json_response['title']).to eq('Test merge_request') end - it "should return 400 when source_branch is missing" do + it "returns 400 when source_branch is missing" do post api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_http_status(400) end - it "should return 400 when target_branch is missing" do + it "returns 400 when target_branch is missing" do post api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_http_status(400) end - it "should return 400 when title is missing" do + it "returns 400 when title is missing" do post api("/projects/#{fork_project.id}/merge_requests", user2), target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id expect(response).to have_http_status(400) end context 'when target_branch is specified' do - it 'should return 422 if not a forked project' do + it 'returns 422 if not a forked project' do post api("/projects/#{project.id}/merge_requests", user), title: 'Test merge_request', target_branch: 'master', @@ -345,7 +353,7 @@ describe API::API, api: true do expect(response).to have_http_status(422) end - it 'should return 422 if targeting a different fork' do + it 'returns 422 if targeting a different fork' do post api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: 'master', @@ -356,7 +364,7 @@ describe API::API, api: true do end end - it "should return 201 when target_branch is specified and for the same project" do + it "returns 201 when target_branch is specified and for the same project" do post api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id expect(response).to have_http_status(201) @@ -387,16 +395,24 @@ describe API::API, api: true do end end + describe "PUT /projects/:id/merge_requests/:merge_request_id to close MR" do + it "returns merge_request" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close" + expect(response).to have_http_status(200) + expect(json_response['state']).to eq('closed') + end + end + describe "PUT /projects/:id/merge_requests/:merge_request_id/merge" do let(:pipeline) { create(:ci_pipeline_without_jobs) } - it "should return merge_request in case of success" do + it "returns merge_request in case of success" do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) expect(response).to have_http_status(200) end - it "should return 406 if branch can't be merged" do + it "returns 406 if branch can't be merged" do allow_any_instance_of(MergeRequest). to receive(:can_be_merged?).and_return(false) @@ -406,14 +422,14 @@ describe API::API, api: true do expect(json_response['message']).to eq('Branch cannot be merged') end - it "should return 405 if merge_request is not open" do + it "returns 405 if merge_request is not open" do merge_request.close put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') end - it "should return 405 if merge_request is a work in progress" do + it "returns 405 if merge_request is a work in progress" do merge_request.update_attribute(:title, "WIP: #{merge_request.title}") put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) expect(response).to have_http_status(405) @@ -429,7 +445,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('405 Method Not Allowed') end - it "should return 401 if user has no permissions to merge" do + it "returns 401 if user has no permissions to merge" do user2 = create(:user) project.team << [user2, :reporter] put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user2) @@ -497,21 +513,21 @@ describe API::API, api: true do expect(json_response['milestone']['id']).to eq(milestone.id) end - it "should return 400 when source_branch is specified" do + it "returns 400 when source_branch is specified" do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), source_branch: "master", target_branch: "master" expect(response).to have_http_status(400) end - it "should return merge_request with renamed target_branch" do + it "returns merge_request with renamed target_branch" do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), target_branch: "wiki" expect(response).to have_http_status(200) expect(json_response['target_branch']).to eq('wiki') end - it 'should allow special label names' do + it 'allows special label names' do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), title: 'new issue', @@ -527,7 +543,7 @@ describe API::API, api: true do end describe "POST /projects/:id/merge_requests/:merge_request_id/comments" do - it "should return comment" do + it "returns comment" do original_count = merge_request.notes.size post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), note: "My comment" @@ -538,12 +554,12 @@ describe API::API, api: true do expect(merge_request.notes.size).to eq(original_count + 1) end - it "should return 400 if note is missing" do + it "returns 400 if note is missing" do post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) expect(response).to have_http_status(400) end - it "should return 404 if note is attached to non existent merge request" do + it "returns 404 if note is attached to non existent merge request" do post api("/projects/#{project.id}/merge_requests/404/comments", user), note: 'My comment' expect(response).to have_http_status(404) @@ -551,7 +567,7 @@ describe API::API, api: true do end describe "GET :id/merge_requests/:merge_request_id/comments" do - it "should return merge_request comments ordered by created_at" do + it "returns merge_request comments ordered by created_at" do get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -561,7 +577,7 @@ describe API::API, api: true do expect(json_response.last['note']).to eq("another comment on a MR") end - it "should return a 404 error if merge_request_id not found" do + it "returns a 404 error if merge_request_id not found" do get api("/projects/#{project.id}/merge_requests/999/comments", user) expect(response).to have_http_status(404) end diff --git a/spec/requests/api/milestones_spec.rb b/spec/requests/api/milestones_spec.rb index 0f4e38b2475..dd192bea432 100644 --- a/spec/requests/api/milestones_spec.rb +++ b/spec/requests/api/milestones_spec.rb @@ -3,22 +3,24 @@ require 'spec_helper' describe API::API, api: true do include ApiHelpers let(:user) { create(:user) } - let!(:project) { create(:project, namespace: user.namespace ) } + let!(:project) { create(:empty_project, namespace: user.namespace ) } let!(:closed_milestone) { create(:closed_milestone, project: project) } let!(:milestone) { create(:milestone, project: project) } before { project.team << [user, :developer] } describe 'GET /projects/:id/milestones' do - it 'should return project milestones' do + it 'returns project milestones' do get api("/projects/#{project.id}/milestones", user) + expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.first['title']).to eq(milestone.title) end - it 'should return a 401 error if user not authenticated' do + it 'returns a 401 error if user not authenticated' do get api("/projects/#{project.id}/milestones") + expect(response).to have_http_status(401) end @@ -42,14 +44,15 @@ describe API::API, api: true do end describe 'GET /projects/:id/milestones/:milestone_id' do - it 'should return a project milestone by id' do + it 'returns a project milestone by id' do get api("/projects/#{project.id}/milestones/#{milestone.id}", user) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(milestone.title) expect(json_response['iid']).to eq(milestone.iid) end - it 'should return a project milestone by iid' do + it 'returns a project milestone by iid' do get api("/projects/#{project.id}/milestones?iid=#{closed_milestone.iid}", user) expect(response.status).to eq 200 @@ -58,56 +61,78 @@ describe API::API, api: true do expect(json_response.first['id']).to eq closed_milestone.id end - it 'should return 401 error if user not authenticated' do + it 'returns 401 error if user not authenticated' do get api("/projects/#{project.id}/milestones/#{milestone.id}") + expect(response).to have_http_status(401) end - it 'should return a 404 error if milestone id not found' do + it 'returns a 404 error if milestone id not found' do get api("/projects/#{project.id}/milestones/1234", user) + expect(response).to have_http_status(404) end end describe 'POST /projects/:id/milestones' do - it 'should create a new project milestone' do + it 'creates a new project milestone' do post api("/projects/#{project.id}/milestones", user), title: 'new milestone' + expect(response).to have_http_status(201) expect(json_response['title']).to eq('new milestone') expect(json_response['description']).to be_nil end - it 'should create a new project milestone with description and due date' do + it 'creates a new project milestone with description and due date' do post api("/projects/#{project.id}/milestones", user), title: 'new milestone', description: 'release', due_date: '2013-03-02' + expect(response).to have_http_status(201) expect(json_response['description']).to eq('release') expect(json_response['due_date']).to eq('2013-03-02') end - it 'should return a 400 error if title is missing' do + it 'returns a 400 error if title is missing' do post api("/projects/#{project.id}/milestones", user) + + expect(response).to have_http_status(400) + end + + it 'returns a 400 error if params are invalid (duplicate title)' do + post api("/projects/#{project.id}/milestones", user), + title: milestone.title, description: 'release', due_date: '2013-03-02' + expect(response).to have_http_status(400) end + + it 'creates a new project with reserved html characters' do + post api("/projects/#{project.id}/milestones", user), title: 'foo & bar 1.1 -> 2.2' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('foo & bar 1.1 -> 2.2') + expect(json_response['description']).to be_nil + end end describe 'PUT /projects/:id/milestones/:milestone_id' do - it 'should update a project milestone' do + it 'updates a project milestone' do put api("/projects/#{project.id}/milestones/#{milestone.id}", user), title: 'updated title' + expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end - it 'should return a 404 error if milestone id not found' do + it 'returns a 404 error if milestone id not found' do put api("/projects/#{project.id}/milestones/1234", user), title: 'updated title' + expect(response).to have_http_status(404) end end describe 'PUT /projects/:id/milestones/:milestone_id to close milestone' do - it 'should update a project milestone' do + it 'updates a project milestone' do put api("/projects/#{project.id}/milestones/#{milestone.id}", user), state_event: 'close' expect(response).to have_http_status(200) @@ -117,7 +142,7 @@ describe API::API, api: true do end describe 'PUT /projects/:id/milestones/:milestone_id to test observer on close' do - it 'should create an activity event when an milestone is closed' do + it 'creates an activity event when an milestone is closed' do expect(Event).to receive(:create) put api("/projects/#{project.id}/milestones/#{milestone.id}", user), @@ -129,20 +154,22 @@ describe API::API, api: true do before do milestone.issues << create(:issue, project: project) end - it 'should return project issues for a particular milestone' do + it 'returns project issues for a particular milestone' do get api("/projects/#{project.id}/milestones/#{milestone.id}/issues", user) + expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.first['milestone']['title']).to eq(milestone.title) end - it 'should return a 401 error if user not authenticated' do + it 'returns a 401 error if user not authenticated' do get api("/projects/#{project.id}/milestones/#{milestone.id}/issues") + expect(response).to have_http_status(401) end describe 'confidential issues' do - let(:public_project) { create(:project, :public) } + let(:public_project) { create(:empty_project, :public) } let(:milestone) { create(:milestone, project: public_project) } let(:issue) { create(:issue, project: public_project) } let(:confidential_issue) { create(:issue, confidential: true, project: public_project) } diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb index 237b4b17eb5..5347cf4f7bc 100644 --- a/spec/requests/api/namespaces_spec.rb +++ b/spec/requests/api/namespaces_spec.rb @@ -9,14 +9,14 @@ describe API::API, api: true do describe "GET /namespaces" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/namespaces") expect(response).to have_http_status(401) end end context "when authenticated as admin" do - it "admin: should return an array of all namespaces" do + it "admin: returns an array of all namespaces" do get api("/namespaces", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -24,7 +24,7 @@ describe API::API, api: true do expect(json_response.length).to eq(Namespace.count) end - it "admin: should return an array of matched namespaces" do + it "admin: returns an array of matched namespaces" do get api("/namespaces?search=#{group1.name}", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -34,7 +34,7 @@ describe API::API, api: true do end context "when authenticated as a regular user" do - it "user: should return an array of namespaces" do + it "user: returns an array of namespaces" do get api("/namespaces", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -42,7 +42,7 @@ describe API::API, api: true do expect(json_response.length).to eq(1) end - it "admin: should return an array of matched namespaces" do + it "admin: returns an array of matched namespaces" do get api("/namespaces?search=#{user.username}", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index 65c53211dd3..063a8706e76 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -25,7 +25,7 @@ describe API::API, api: true do let!(:cross_reference_note) do create :note, noteable: ext_issue, project: ext_proj, - note: "mentioned in issue #{private_issue.to_reference(ext_proj)}", + note: "Mentioned in issue #{private_issue.to_reference(ext_proj)}", system: true end @@ -37,7 +37,7 @@ describe API::API, api: true do end context "when noteable is an Issue" do - it "should return an array of issue notes" do + it "returns an array of issue notes" do get api("/projects/#{project.id}/issues/#{issue.id}/notes", user) expect(response).to have_http_status(200) @@ -45,14 +45,14 @@ describe API::API, api: true do expect(json_response.first['body']).to eq(issue_note.note) end - it "should return a 404 error when issue id not found" do + it "returns a 404 error when issue id not found" do get api("/projects/#{project.id}/issues/12345/notes", user) expect(response).to have_http_status(404) end context "and current user cannot view the notes" do - it "should return an empty array" do + it "returns an empty array" do get api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes", user) expect(response).to have_http_status(200) @@ -71,7 +71,7 @@ describe API::API, api: true do end context "and current user can view the note" do - it "should return an empty array" do + it "returns an empty array" do get api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes", private_user) expect(response).to have_http_status(200) @@ -83,7 +83,7 @@ describe API::API, api: true do end context "when noteable is a Snippet" do - it "should return an array of snippet notes" do + it "returns an array of snippet notes" do get api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) expect(response).to have_http_status(200) @@ -91,7 +91,7 @@ describe API::API, api: true do expect(json_response.first['body']).to eq(snippet_note.note) end - it "should return a 404 error when snippet id not found" do + it "returns a 404 error when snippet id not found" do get api("/projects/#{project.id}/snippets/42/notes", user) expect(response).to have_http_status(404) @@ -105,7 +105,7 @@ describe API::API, api: true do end context "when noteable is a Merge Request" do - it "should return an array of merge_requests notes" do + it "returns an array of merge_requests notes" do get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/notes", user) expect(response).to have_http_status(200) @@ -113,7 +113,7 @@ describe API::API, api: true do expect(json_response.first['body']).to eq(merge_request_note.note) end - it "should return a 404 error if merge request id not found" do + it "returns a 404 error if merge request id not found" do get api("/projects/#{project.id}/merge_requests/4444/notes", user) expect(response).to have_http_status(404) @@ -129,21 +129,21 @@ describe API::API, api: true do describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do context "when noteable is an Issue" do - it "should return an issue note by id" do + it "returns an issue note by id" do get api("/projects/#{project.id}/issues/#{issue.id}/notes/#{issue_note.id}", user) expect(response).to have_http_status(200) expect(json_response['body']).to eq(issue_note.note) end - it "should return a 404 error if issue note not found" do + it "returns a 404 error if issue note not found" do get api("/projects/#{project.id}/issues/#{issue.id}/notes/12345", user) expect(response).to have_http_status(404) end context "and current user cannot view the note" do - it "should return a 404 error" do + it "returns a 404 error" do get api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes/#{cross_reference_note.id}", user) expect(response).to have_http_status(404) @@ -160,7 +160,7 @@ describe API::API, api: true do end context "and current user can view the note" do - it "should return an issue note by id" do + it "returns an issue note by id" do get api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes/#{cross_reference_note.id}", private_user) expect(response).to have_http_status(200) @@ -171,14 +171,14 @@ describe API::API, api: true do end context "when noteable is a Snippet" do - it "should return a snippet note by id" do + it "returns a snippet note by id" do get api("/projects/#{project.id}/snippets/#{snippet.id}/notes/#{snippet_note.id}", user) expect(response).to have_http_status(200) expect(json_response['body']).to eq(snippet_note.note) end - it "should return a 404 error if snippet note not found" do + it "returns a 404 error if snippet note not found" do get api("/projects/#{project.id}/snippets/#{snippet.id}/notes/12345", user) expect(response).to have_http_status(404) @@ -188,7 +188,7 @@ describe API::API, api: true do describe "POST /projects/:id/noteable/:noteable_id/notes" do context "when noteable is an Issue" do - it "should create a new issue note" do + it "creates a new issue note" do post api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: 'hi!' expect(response).to have_http_status(201) @@ -196,13 +196,13 @@ describe API::API, api: true do expect(json_response['author']['username']).to eq(user.username) end - it "should return a 400 bad request error if body not given" do + it "returns a 400 bad request error if body not given" do post api("/projects/#{project.id}/issues/#{issue.id}/notes", user) expect(response).to have_http_status(400) end - it "should return a 401 unauthorized error if user not authenticated" do + it "returns a 401 unauthorized error if user not authenticated" do post api("/projects/#{project.id}/issues/#{issue.id}/notes"), body: 'hi!' expect(response).to have_http_status(401) @@ -220,10 +220,19 @@ describe API::API, api: true do expect(Time.parse(json_response['created_at'])).to be_within(1.second).of(creation_time) end end + + context 'when the user is posting an award emoji' do + it 'returns an award emoji' do + post api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: ':+1:' + + expect(response).to have_http_status(201) + expect(json_response['awardable_id']).to eq issue.id + end + end end context "when noteable is a Snippet" do - it "should create a new snippet note" do + it "creates a new snippet note" do post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user), body: 'hi!' expect(response).to have_http_status(201) @@ -231,13 +240,13 @@ describe API::API, api: true do expect(json_response['author']['username']).to eq(user.username) end - it "should return a 400 bad request error if body not given" do + it "returns a 400 bad request error if body not given" do post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) expect(response).to have_http_status(400) end - it "should return a 401 unauthorized error if user not authenticated" do + it "returns a 401 unauthorized error if user not authenticated" do post api("/projects/#{project.id}/snippets/#{snippet.id}/notes"), body: 'hi!' expect(response).to have_http_status(401) @@ -267,7 +276,7 @@ describe API::API, api: true do end describe "POST /projects/:id/noteable/:noteable_id/notes to test observer on create" do - it "should create an activity event when an issue note is created" do + it "creates an activity event when an issue note is created" do expect(Event).to receive(:create) post api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: 'hi!' @@ -276,7 +285,7 @@ describe API::API, api: true do describe 'PUT /projects/:id/noteable/:noteable_id/notes/:note_id' do context 'when noteable is an Issue' do - it 'should return modified note' do + it 'returns modified note' do put api("/projects/#{project.id}/issues/#{issue.id}/"\ "notes/#{issue_note.id}", user), body: 'Hello!' @@ -284,14 +293,14 @@ describe API::API, api: true do expect(json_response['body']).to eq('Hello!') end - it 'should return a 404 error when note id not found' do + it 'returns a 404 error when note id not found' do put api("/projects/#{project.id}/issues/#{issue.id}/notes/12345", user), body: 'Hello!' expect(response).to have_http_status(404) end - it 'should return a 400 bad request error if body not given' do + it 'returns a 400 bad request error if body not given' do put api("/projects/#{project.id}/issues/#{issue.id}/"\ "notes/#{issue_note.id}", user) @@ -300,7 +309,7 @@ describe API::API, api: true do end context 'when noteable is a Snippet' do - it 'should return modified note' do + it 'returns modified note' do put api("/projects/#{project.id}/snippets/#{snippet.id}/"\ "notes/#{snippet_note.id}", user), body: 'Hello!' @@ -308,7 +317,7 @@ describe API::API, api: true do expect(json_response['body']).to eq('Hello!') end - it 'should return a 404 error when note id not found' do + it 'returns a 404 error when note id not found' do put api("/projects/#{project.id}/snippets/#{snippet.id}/"\ "notes/12345", user), body: "Hello!" @@ -317,7 +326,7 @@ describe API::API, api: true do end context 'when noteable is a Merge Request' do - it 'should return modified note' do + it 'returns modified note' do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/"\ "notes/#{merge_request_note.id}", user), body: 'Hello!' @@ -325,7 +334,7 @@ describe API::API, api: true do expect(json_response['body']).to eq('Hello!') end - it 'should return a 404 error when note id not found' do + it 'returns a 404 error when note id not found' do put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/"\ "notes/12345", user), body: "Hello!" diff --git a/spec/requests/api/notification_settings_spec.rb b/spec/requests/api/notification_settings_spec.rb new file mode 100644 index 00000000000..e6d8a5ee954 --- /dev/null +++ b/spec/requests/api/notification_settings_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let!(:group) { create(:group) } + let!(:project) { create(:project, :public, creator_id: user.id, namespace: group) } + + describe "GET /notification_settings" do + it "returns global notification settings for the current user" do + get api("/notification_settings", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_a Hash + expect(json_response['notification_email']).to eq(user.notification_email) + expect(json_response['level']).to eq(user.global_notification_setting.level) + end + end + + describe "PUT /notification_settings" do + let(:email) { create(:email, user: user) } + + it "updates global notification settings for the current user" do + put api("/notification_settings", user), { level: 'watch', notification_email: email.email } + + expect(response).to have_http_status(200) + expect(json_response['notification_email']).to eq(email.email) + expect(user.reload.notification_email).to eq(email.email) + expect(json_response['level']).to eq(user.reload.global_notification_setting.level) + end + end + + describe "PUT /notification_settings" do + it "fails on non-user email address" do + put api("/notification_settings", user), { notification_email: 'invalid@example.com' } + + expect(response).to have_http_status(400) + end + end + + describe "GET /groups/:id/notification_settings" do + it "returns group level notification settings for the current user" do + get api("/groups/#{group.id}/notification_settings", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_a Hash + expect(json_response['level']).to eq(user.notification_settings_for(group).level) + end + end + + describe "PUT /groups/:id/notification_settings" do + it "updates group level notification settings for the current user" do + put api("/groups/#{group.id}/notification_settings", user), { level: 'watch' } + + expect(response).to have_http_status(200) + expect(json_response['level']).to eq(user.reload.notification_settings_for(group).level) + end + end + + describe "GET /projects/:id/notification_settings" do + it "returns project level notification settings for the current user" do + get api("/projects/#{project.id}/notification_settings", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_a Hash + expect(json_response['level']).to eq(user.notification_settings_for(project).level) + end + end + + describe "PUT /projects/:id/notification_settings" do + it "updates project level notification settings for the current user" do + put api("/projects/#{project.id}/notification_settings", user), { level: 'custom', new_note: true } + + expect(response).to have_http_status(200) + expect(json_response['level']).to eq(user.reload.notification_settings_for(project).level) + expect(json_response['events']['new_note']).to eq(true) + expect(json_response['events']['new_issue']).to eq(false) + end + end + + describe "PUT /projects/:id/notification_settings" do + it "fails on invalid level" do + put api("/projects/#{project.id}/notification_settings", user), { level: 'invalid' } + + expect(response).to have_http_status(400) + end + end +end diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb new file mode 100644 index 00000000000..7e2cc50e591 --- /dev/null +++ b/spec/requests/api/oauth_tokens_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + context 'Resource Owner Password Credentials' do + def request_oauth_token(user) + post '/oauth/token', username: user.username, password: user.password, grant_type: 'password' + end + + context 'when user has 2FA enabled' do + it 'does not create an access token' do + user = create(:user, :two_factor) + + request_oauth_token(user) + + expect(response).to have_http_status(401) + expect(json_response['error']).to eq('invalid_grant') + end + end + + context 'when user does not have 2FA enabled' do + it 'creates an access token' do + user = create(:user) + + request_oauth_token(user) + + expect(response).to have_http_status(200) + expect(json_response['access_token']).not_to be_nil + end + end + end +end diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb new file mode 100644 index 00000000000..7011bdc9ec0 --- /dev/null +++ b/spec/requests/api/pipelines_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:project, creator_id: user.id) } + + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + before { project.team << [user, :master] } + + describe 'GET /projects/:id/pipelines ' do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/projects/#{project.id}/pipelines", user) } + end + + context 'authorized user' do + it 'returns project pipelines' do + get api("/projects/#{project.id}/pipelines", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['sha']).to match /\A\h{40}\z/ + expect(json_response.first['id']).to eq pipeline.id + end + end + + context 'unauthorized user' do + it 'does not return project pipelines' do + get api("/projects/#{project.id}/pipelines", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + + describe 'GET /projects/:id/pipelines/:pipeline_id' do + context 'authorized user' do + it 'returns project pipelines' do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + end + + it 'returns 404 when it does not exist' do + get api("/projects/#{project.id}/pipelines/123456", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Not found' + expect(json_response['id']).to be nil + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/retry' do + context 'authorized user' do + let!(:pipeline) do + create(:ci_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + it 'retries failed builds' do + expect do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user) + end.to change { pipeline.builds.count }.from(1).to(2) + + expect(response).to have_http_status(201) + expect(build.reload.retried?).to be true + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + context 'authorized user' do + it 'retries failed builds' do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user) + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq('canceled') + end + end + + context 'user without proper access rights' do + let!(:reporter) { create(:user) } + + before { project.team << [reporter, :reporter] } + + it 'rejects the action' do + post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter) + + expect(response).to have_http_status(403) + expect(pipeline.reload.status).to eq('pending') + end + end + end +end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index fd1fffa6223..cfcdcad74cd 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -7,9 +7,9 @@ describe API::API, 'ProjectHooks', api: true do let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let!(:hook) do create(:project_hook, - project: project, url: "http://example.com", - push_events: true, merge_requests_events: true, tag_push_events: true, - issues_events: true, note_events: true, build_events: true, + :all_events_enabled, + project: project, + url: 'http://example.com', enable_ssl_verification: true) end @@ -20,7 +20,7 @@ describe API::API, 'ProjectHooks', api: true do describe "GET /projects/:id/hooks" do context "authorized user" do - it "should return project hooks" do + it "returns project hooks" do get api("/projects/#{project.id}/hooks", user) expect(response).to have_http_status(200) @@ -33,12 +33,14 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response.first['tag_push_events']).to eq(true) expect(json_response.first['note_events']).to eq(true) expect(json_response.first['build_events']).to eq(true) + expect(json_response.first['pipeline_events']).to eq(true) + expect(json_response.first['wiki_page_events']).to eq(true) expect(json_response.first['enable_ssl_verification']).to eq(true) end end context "unauthorized user" do - it "should not access project hooks" do + it "does not access project hooks" do get api("/projects/#{project.id}/hooks", user3) expect(response).to have_http_status(403) end @@ -47,7 +49,7 @@ describe API::API, 'ProjectHooks', api: true do describe "GET /projects/:id/hooks/:hook_id" do context "authorized user" do - it "should return a project hook" do + it "returns a project hook" do get api("/projects/#{project.id}/hooks/#{hook.id}", user) expect(response).to have_http_status(200) expect(json_response['url']).to eq(hook.url) @@ -56,30 +58,33 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) + expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['pipeline_events']).to eq(hook.pipeline_events) + expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) end - it "should return a 404 error if hook id is not available" do + it "returns a 404 error if hook id is not available" do get api("/projects/#{project.id}/hooks/1234", user) expect(response).to have_http_status(404) end end context "unauthorized user" do - it "should not access an existing hook" do + it "does not access an existing hook" do get api("/projects/#{project.id}/hooks/#{hook.id}", user3) expect(response).to have_http_status(403) end end - it "should return a 404 error if hook id is not available" do + it "returns a 404 error if hook id is not available" do get api("/projects/#{project.id}/hooks/1234", user) expect(response).to have_http_status(404) end end describe "POST /projects/:id/hooks" do - it "should add hook to project" do + it "adds hook to project" do expect do post api("/projects/#{project.id}/hooks", user), url: "http://example.com", issues_events: true end.to change {project.hooks.count}.by(1) @@ -91,22 +96,24 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response['tag_push_events']).to eq(false) expect(json_response['note_events']).to eq(false) expect(json_response['build_events']).to eq(false) + expect(json_response['pipeline_events']).to eq(false) + expect(json_response['wiki_page_events']).to eq(false) expect(json_response['enable_ssl_verification']).to eq(true) end - it "should return a 400 error if url not given" do + it "returns a 400 error if url not given" do post api("/projects/#{project.id}/hooks", user) expect(response).to have_http_status(400) end - it "should return a 422 error if url not valid" do + it "returns a 422 error if url not valid" do post api("/projects/#{project.id}/hooks", user), "url" => "ftp://example.com" expect(response).to have_http_status(422) end end describe "PUT /projects/:id/hooks/:hook_id" do - it "should update an existing project hook" do + it "updates an existing project hook" do put api("/projects/#{project.id}/hooks/#{hook.id}", user), url: 'http://example.org', push_events: false expect(response).to have_http_status(200) @@ -116,49 +123,53 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) + expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['pipeline_events']).to eq(hook.pipeline_events) + expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) end - it "should return 404 error if hook id not found" do + it "returns 404 error if hook id not found" do put api("/projects/#{project.id}/hooks/1234", user), url: 'http://example.org' expect(response).to have_http_status(404) end - it "should return 400 error if url is not given" do + it "returns 400 error if url is not given" do put api("/projects/#{project.id}/hooks/#{hook.id}", user) expect(response).to have_http_status(400) end - it "should return a 422 error if url is not valid" do + it "returns a 422 error if url is not valid" do put api("/projects/#{project.id}/hooks/#{hook.id}", user), url: 'ftp://example.com' expect(response).to have_http_status(422) end end describe "DELETE /projects/:id/hooks/:hook_id" do - it "should delete hook from project" do + it "deletes hook from project" do expect do delete api("/projects/#{project.id}/hooks/#{hook.id}", user) end.to change {project.hooks.count}.by(-1) expect(response).to have_http_status(200) end - it "should return success when deleting hook" do + it "returns success when deleting hook" do delete api("/projects/#{project.id}/hooks/#{hook.id}", user) expect(response).to have_http_status(200) end - it "should return a 404 error when deleting non existent hook" do + it "returns a 404 error when deleting non existent hook" do delete api("/projects/#{project.id}/hooks/42", user) expect(response).to have_http_status(404) end - it "should return a 405 error if hook id not given" do + it "returns a 404 error if hook id not given" do delete api("/projects/#{project.id}/hooks", user) - expect(response).to have_http_status(405) + + expect(response).to have_http_status(404) end - it "shold return a 404 if a user attempts to delete project hooks he/she does not own" do + it "returns a 404 if a user attempts to delete project hooks he/she does not own" do test_user = create(:user) other_project = create(:project) other_project.team << [test_user, :master] diff --git a/spec/requests/api/project_members_spec.rb b/spec/requests/api/project_members_spec.rb deleted file mode 100644 index 9a7c1da4401..00000000000 --- a/spec/requests/api/project_members_spec.rb +++ /dev/null @@ -1,166 +0,0 @@ -require 'spec_helper' - -describe API::API, api: true do - include ApiHelpers - let(:user) { create(:user) } - let(:user2) { create(:user) } - let(:user3) { create(:user) } - let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } - let(:project_member) { create(:project_member, :master, user: user, project: project) } - let(:project_member2) { create(:project_member, :developer, user: user3, project: project) } - - describe "GET /projects/:id/members" do - before { project_member } - before { project_member2 } - - it "should return project team members" do - get api("/projects/#{project.id}/members", user) - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.count).to eq(2) - expect(json_response.map { |u| u['username'] }).to include user.username - end - - it "finds team members with query string" do - get api("/projects/#{project.id}/members", user), query: user.username - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.count).to eq(1) - expect(json_response.first['username']).to eq(user.username) - end - - it "should return a 404 error if id not found" do - get api("/projects/9999/members", user) - expect(response).to have_http_status(404) - end - end - - describe "GET /projects/:id/members/:user_id" do - before { project_member } - - it "should return project team member" do - get api("/projects/#{project.id}/members/#{user.id}", user) - expect(response).to have_http_status(200) - expect(json_response['username']).to eq(user.username) - expect(json_response['access_level']).to eq(ProjectMember::MASTER) - end - - it "should return a 404 error if user id not found" do - get api("/projects/#{project.id}/members/1234", user) - expect(response).to have_http_status(404) - end - end - - describe "POST /projects/:id/members" do - it "should add user to project team" do - expect do - post api("/projects/#{project.id}/members", user), user_id: user2.id, access_level: ProjectMember::DEVELOPER - end.to change { ProjectMember.count }.by(1) - - expect(response).to have_http_status(201) - expect(json_response['username']).to eq(user2.username) - expect(json_response['access_level']).to eq(ProjectMember::DEVELOPER) - end - - it "should return a 201 status if user is already project member" do - post api("/projects/#{project.id}/members", user), - user_id: user2.id, - access_level: ProjectMember::DEVELOPER - expect do - post api("/projects/#{project.id}/members", user), user_id: user2.id, access_level: ProjectMember::DEVELOPER - end.not_to change { ProjectMember.count } - - expect(response).to have_http_status(201) - expect(json_response['username']).to eq(user2.username) - expect(json_response['access_level']).to eq(ProjectMember::DEVELOPER) - end - - it "should return a 400 error when user id is not given" do - post api("/projects/#{project.id}/members", user), access_level: ProjectMember::MASTER - expect(response).to have_http_status(400) - end - - it "should return a 400 error when access level is not given" do - post api("/projects/#{project.id}/members", user), user_id: user2.id - expect(response).to have_http_status(400) - end - - it "should return a 422 error when access level is not known" do - post api("/projects/#{project.id}/members", user), user_id: user2.id, access_level: 1234 - expect(response).to have_http_status(422) - end - end - - describe "PUT /projects/:id/members/:user_id" do - before { project_member2 } - - it "should update project team member" do - put api("/projects/#{project.id}/members/#{user3.id}", user), access_level: ProjectMember::MASTER - expect(response).to have_http_status(200) - expect(json_response['username']).to eq(user3.username) - expect(json_response['access_level']).to eq(ProjectMember::MASTER) - end - - it "should return a 404 error if user_id is not found" do - put api("/projects/#{project.id}/members/1234", user), access_level: ProjectMember::MASTER - expect(response).to have_http_status(404) - end - - it "should return a 400 error when access level is not given" do - put api("/projects/#{project.id}/members/#{user3.id}", user) - expect(response).to have_http_status(400) - end - - it "should return a 422 error when access level is not known" do - put api("/projects/#{project.id}/members/#{user3.id}", user), access_level: 123 - expect(response).to have_http_status(422) - end - end - - describe "DELETE /projects/:id/members/:user_id" do - before do - project_member - project_member2 - end - - it "should remove user from project team" do - expect do - delete api("/projects/#{project.id}/members/#{user3.id}", user) - end.to change { ProjectMember.count }.by(-1) - end - - it "should return 200 if team member is not part of a project" do - delete api("/projects/#{project.id}/members/#{user3.id}", user) - expect do - delete api("/projects/#{project.id}/members/#{user3.id}", user) - end.not_to change { ProjectMember.count } - expect(response).to have_http_status(200) - end - - it "should return 200 if team member already removed" do - delete api("/projects/#{project.id}/members/#{user3.id}", user) - delete api("/projects/#{project.id}/members/#{user3.id}", user) - expect(response).to have_http_status(200) - end - - it "should return 200 OK when the user was not member" do - expect do - delete api("/projects/#{project.id}/members/1000000", user) - end.to change { ProjectMember.count }.by(0) - expect(response).to have_http_status(200) - expect(json_response['id']).to eq(1000000) - expect(json_response['message']).to eq('Access revoked') - end - - context 'when the user is not an admin or owner' do - it 'can leave the project' do - expect do - delete api("/projects/#{project.id}/members/#{user3.id}", user3) - end.to change { ProjectMember.count }.by(-1) - - expect(response).to have_http_status(200) - expect(json_response['id']).to eq(project_member2.id) - end - end - end -end diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index 4ebde201941..01148f0a05e 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -17,7 +17,7 @@ describe API::API, api: true do end describe 'GET /projects/:project_id/snippets/' do - it 'all snippets available to team member' do + it 'returns all snippets available to team member' do project = create(:project, :public) user = create(:user) project.team << [user, :developer] @@ -30,6 +30,7 @@ describe API::API, api: true do expect(response).to have_http_status(200) expect(json_response.size).to eq(3) expect(json_response.map{ |snippet| snippet['id']} ).to include(public_snippet.id, internal_snippet.id, private_snippet.id) + expect(json_response.last).to have_key('web_url') end it 'hides private snippets from regular user' do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 8c6a7e6529d..973928d007a 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -43,14 +43,14 @@ describe API::API, api: true do before { project } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api('/projects') expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should return an array of projects' do + it 'returns an array of projects' do get api('/projects', user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -58,22 +58,22 @@ describe API::API, api: true do expect(json_response.first['owner']['username']).to eq(user.username) end - it 'should include the project labels as the tag_list' do + it 'includes the project labels as the tag_list' do get api('/projects', user) expect(response.status).to eq 200 expect(json_response).to be_an Array expect(json_response.first.keys).to include('tag_list') end - it 'should include open_issues_count' do + it 'includes open_issues_count' do get api('/projects', user) expect(response.status).to eq 200 expect(json_response).to be_an Array expect(json_response.first.keys).to include('open_issues_count') end - it 'should not include open_issues_count' do - project.update_attributes( { issues_enabled: false } ) + it 'does not include open_issues_count' do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) get api('/projects', user) expect(response.status).to eq 200 @@ -94,7 +94,7 @@ describe API::API, api: true do end context 'and using search' do - it 'should return searched project' do + it 'returns searched project' do get api('/projects', user), { search: project.name } expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -103,21 +103,21 @@ describe API::API, api: true do end context 'and using the visibility filter' do - it 'should filter based on private visibility param' do + it 'filters based on private visibility param' do get api('/projects', user), { visibility: 'private' } expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(user.namespace.projects.where(visibility_level: Gitlab::VisibilityLevel::PRIVATE).count) end - it 'should filter based on internal visibility param' do + it 'filters based on internal visibility param' do get api('/projects', user), { visibility: 'internal' } expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(user.namespace.projects.where(visibility_level: Gitlab::VisibilityLevel::INTERNAL).count) end - it 'should filter based on public visibility param' do + it 'filters based on public visibility param' do get api('/projects', user), { visibility: 'public' } expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -131,7 +131,7 @@ describe API::API, api: true do project3 end - it 'should return the correct order when sorted by id' do + it 'returns the correct order when sorted by id' do get api('/projects', user), { order_by: 'id', sort: 'desc' } expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -145,21 +145,21 @@ describe API::API, api: true do before { project } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api('/projects/all') expect(response).to have_http_status(401) end end context 'when authenticated as regular user' do - it 'should return authentication error' do + it 'returns authentication error' do get api('/projects/all', user) expect(response).to have_http_status(403) end end context 'when authenticated as admin' do - it 'should return an array of all projects' do + it 'returns an array of all projects' do get api('/projects/all', admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -175,6 +175,36 @@ describe API::API, api: true do end end + describe 'GET /projects/visible' do + let(:public_project) { create(:project, :public) } + + before do + public_project + project + project2 + project3 + project4 + end + + it 'returns the projects viewable by the user' do + get api('/projects/visible', user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }). + to contain_exactly(public_project.id, project.id, project2.id, project3.id) + end + + it 'shows only public projects when the user only has access to those' do + get api('/projects/visible', user2) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }). + to contain_exactly(public_project.id) + end + end + describe 'GET /projects/starred' do let(:public_project) { create(:project, :public) } @@ -183,7 +213,7 @@ describe API::API, api: true do user3.update_attributes(starred_projects: [project, project2, project3, public_project]) end - it 'should return the starred projects viewable by the user' do + it 'returns the starred projects viewable by the user' do get api('/projects/starred', user3) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -193,7 +223,7 @@ describe API::API, api: true do describe 'POST /projects' do context 'maximum number of projects reached' do - it 'should not create new project and respond with 403' do + it 'does not create new project and respond with 403' do allow_any_instance_of(User).to receive(:projects_limit_left).and_return(0) expect { post api('/projects', user2), name: 'foo' }. to change {Project.count}.by(0) @@ -201,88 +231,109 @@ describe API::API, api: true do end end - it 'should create new project without path and return 201' do + it 'creates new project without path and return 201' do expect { post api('/projects', user), name: 'foo' }. to change { Project.count }.by(1) expect(response).to have_http_status(201) end - it 'should create last project before reaching project limit' do + it 'creates last project before reaching project limit' do allow_any_instance_of(User).to receive(:projects_limit_left).and_return(1) post api('/projects', user2), name: 'foo' expect(response).to have_http_status(201) end - it 'should not create new project without name and return 400' do + it 'does not create new project without name and return 400' do expect { post api('/projects', user) }.not_to change { Project.count } expect(response).to have_http_status(400) end - it "should assign attributes to project" do + it "assigns attributes to project" do project = attributes_for(:project, { path: 'camelCasePath', description: FFaker::Lorem.sentence, issues_enabled: false, merge_requests_enabled: false, - wiki_enabled: false + wiki_enabled: false, + only_allow_merge_if_build_succeeds: false, + request_access_enabled: true }) post api('/projects', user), project project.each_pair do |k, v| + next if %i[has_external_issue_tracker issues_enabled merge_requests_enabled wiki_enabled].include?(k) expect(json_response[k.to_s]).to eq(v) end + + # Check feature permissions attributes + project = Project.find_by_path(project[:path]) + expect(project.project_feature.issues_access_level).to eq(ProjectFeature::DISABLED) + expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::DISABLED) + expect(project.project_feature.wiki_access_level).to eq(ProjectFeature::DISABLED) end - it 'should set a project as public' do + it 'sets a project as public' do project = attributes_for(:project, :public) post api('/projects', user), project expect(json_response['public']).to be_truthy expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) end - it 'should set a project as public using :public' do + it 'sets a project as public using :public' do project = attributes_for(:project, { public: true }) post api('/projects', user), project expect(json_response['public']).to be_truthy expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) end - it 'should set a project as internal' do + it 'sets a project as internal' do project = attributes_for(:project, :internal) post api('/projects', user), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'should set a project as internal overriding :public' do + it 'sets a project as internal overriding :public' do project = attributes_for(:project, :internal, { public: true }) post api('/projects', user), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'should set a project as private' do + it 'sets a project as private' do project = attributes_for(:project, :private) post api('/projects', user), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end - it 'should set a project as private using :public' do + it 'sets a project as private using :public' do project = attributes_for(:project, { public: false }) post api('/projects', user), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end + it 'sets a project as allowing merge even if build fails' do + project = attributes_for(:project, { only_allow_merge_if_build_succeeds: false }) + post api('/projects', user), project + expect(json_response['only_allow_merge_if_build_succeeds']).to be_falsey + end + + it 'sets a project as allowing merge only if build succeeds' do + project = attributes_for(:project, { only_allow_merge_if_build_succeeds: true }) + post api('/projects', user), project + expect(json_response['only_allow_merge_if_build_succeeds']).to be_truthy + end + context 'when a visibility level is restricted' do before do @project = attributes_for(:project, { public: true }) stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end - it 'should not allow a non-admin to use a restricted visibility level' do + it 'does not allow a non-admin to use a restricted visibility level' do post api('/projects', user), @project expect(response).to have_http_status(400) @@ -291,7 +342,7 @@ describe API::API, api: true do ) end - it 'should allow an admin to override restricted visibility settings' do + it 'allows an admin to override restricted visibility settings' do post api('/projects', admin), @project expect(json_response['public']).to be_truthy expect(json_response['visibility_level']).to( @@ -310,7 +361,7 @@ describe API::API, api: true do expect(response).to have_http_status(201) end - it 'should respond with 400 on failure and not project' do + it 'responds with 400 on failure and not project' do expect { post api("/projects/user/#{user.id}", admin) }. not_to change { Project.count } @@ -327,63 +378,76 @@ describe API::API, api: true do ]) end - it 'should assign attributes to project' do + it 'assigns attributes to project' do project = attributes_for(:project, { description: FFaker::Lorem.sentence, issues_enabled: false, merge_requests_enabled: false, - wiki_enabled: false + wiki_enabled: false, + request_access_enabled: true }) post api("/projects/user/#{user.id}", admin), project project.each_pair do |k, v| - next if k == :path + next if %i[has_external_issue_tracker path].include?(k) expect(json_response[k.to_s]).to eq(v) end end - it 'should set a project as public' do + it 'sets a project as public' do project = attributes_for(:project, :public) post api("/projects/user/#{user.id}", admin), project expect(json_response['public']).to be_truthy expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) end - it 'should set a project as public using :public' do + it 'sets a project as public using :public' do project = attributes_for(:project, { public: true }) post api("/projects/user/#{user.id}", admin), project expect(json_response['public']).to be_truthy expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) end - it 'should set a project as internal' do + it 'sets a project as internal' do project = attributes_for(:project, :internal) post api("/projects/user/#{user.id}", admin), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'should set a project as internal overriding :public' do + it 'sets a project as internal overriding :public' do project = attributes_for(:project, :internal, { public: true }) post api("/projects/user/#{user.id}", admin), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'should set a project as private' do + it 'sets a project as private' do project = attributes_for(:project, :private) post api("/projects/user/#{user.id}", admin), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end - it 'should set a project as private using :public' do + it 'sets a project as private using :public' do project = attributes_for(:project, { public: false }) post api("/projects/user/#{user.id}", admin), project expect(json_response['public']).to be_falsey expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end + + it 'sets a project as allowing merge even if build fails' do + project = attributes_for(:project, { only_allow_merge_if_build_succeeds: false }) + post api("/projects/user/#{user.id}", admin), project + expect(json_response['only_allow_merge_if_build_succeeds']).to be_falsey + end + + it 'sets a project as allowing merge only if build succeeds' do + project = attributes_for(:project, { only_allow_merge_if_build_succeeds: true }) + post api("/projects/user/#{user.id}", admin), project + expect(json_response['only_allow_merge_if_build_succeeds']).to be_truthy + end end describe "POST /projects/:id/uploads" do @@ -444,27 +508,28 @@ describe API::API, api: true do expect(json_response['shared_with_groups'][0]['group_id']).to eq(group.id) expect(json_response['shared_with_groups'][0]['group_name']).to eq(group.name) expect(json_response['shared_with_groups'][0]['group_access_level']).to eq(link.group_access) + expect(json_response['only_allow_merge_if_build_succeeds']).to eq(project.only_allow_merge_if_build_succeeds) end - it 'should return a project by path name' do + it 'returns a project by path name' do get api("/projects/#{project.id}", user) expect(response).to have_http_status(200) expect(json_response['name']).to eq(project.name) end - it 'should return a 404 error if not found' do + it 'returns a 404 error if not found' do get api('/projects/42', user) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Project Not Found') end - it 'should return a 404 error if user is not a member' do + it 'returns a 404 error if user is not a member' do other_user = create(:user) get api("/projects/#{project.id}", other_user) expect(response).to have_http_status(404) end - it 'should handle users with dots' do + it 'handles users with dots' do dot_user = create(:user, username: 'dot.user') project = create(:project, creator_id: dot_user.id, namespace: dot_user.namespace) @@ -504,7 +569,7 @@ describe API::API, api: true do before { project2.group.add_owner(user) } - it 'should set the owner and return 200' do + it 'sets the owner and return 200' do get api("/projects/#{project2.id}", user) expect(response).to have_http_status(200) @@ -523,37 +588,39 @@ describe API::API, api: true do before do note = create(:note_on_issue, note: 'What an awesome day!', project: project) EventCreateService.new.leave_note(note, note.author) - get api("/projects/#{project.id}/events", user) end - it { expect(response).to have_http_status(200) } + it 'returns all events' do + get api("/projects/#{project.id}/events", user) - context 'joined event' do - let(:json_event) { json_response[1] } + expect(response).to have_http_status(200) - it { expect(json_event['action_name']).to eq('joined') } - it { expect(json_event['project_id'].to_i).to eq(project.id) } - it { expect(json_event['author_username']).to eq(user3.username) } - it { expect(json_event['author']['name']).to eq(user3.name) } - end + first_event = json_response.first + + expect(first_event['action_name']).to eq('commented on') + expect(first_event['note']['body']).to eq('What an awesome day!') - context 'comment event' do - let(:json_event) { json_response.first } + last_event = json_response.last - it { expect(json_event['action_name']).to eq('commented on') } - it { expect(json_event['note']['body']).to eq('What an awesome day!') } + expect(last_event['action_name']).to eq('joined') + expect(last_event['project_id'].to_i).to eq(project.id) + expect(last_event['author_username']).to eq(user3.username) + expect(last_event['author']['name']).to eq(user3.name) end end - it 'should return a 404 error if not found' do + it 'returns a 404 error if not found' do get api('/projects/42/events', user) + expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Project Not Found') end - it 'should return a 404 error if user is not a member' do + it 'returns a 404 error if user is not a member' do other_user = create(:user) + get api("/projects/#{project.id}/events", other_user) + expect(response).to have_http_status(404) end end @@ -561,7 +628,7 @@ describe API::API, api: true do describe 'GET /projects/:id/snippets' do before { snippet } - it 'should return an array of project snippets' do + it 'returns an array of project snippets' do get api("/projects/#{project.id}/snippets", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -570,20 +637,20 @@ describe API::API, api: true do end describe 'GET /projects/:id/snippets/:snippet_id' do - it 'should return a project snippet' do + it 'returns a project snippet' do get api("/projects/#{project.id}/snippets/#{snippet.id}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(snippet.title) end - it 'should return a 404 error if snippet id not found' do + it 'returns a 404 error if snippet id not found' do get api("/projects/#{project.id}/snippets/1234", user) expect(response).to have_http_status(404) end end describe 'POST /projects/:id/snippets' do - it 'should create a new project snippet' do + it 'creates a new project snippet' do post api("/projects/#{project.id}/snippets", user), title: 'api test', file_name: 'sample.rb', code: 'test', visibility_level: '0' @@ -591,14 +658,14 @@ describe API::API, api: true do expect(json_response['title']).to eq('api test') end - it 'should return a 400 error if invalid snippet is given' do + it 'returns a 400 error if invalid snippet is given' do post api("/projects/#{project.id}/snippets", user) expect(status).to eq(400) end end describe 'PUT /projects/:id/snippets/:snippet_id' do - it 'should update an existing project snippet' do + it 'updates an existing project snippet' do put api("/projects/#{project.id}/snippets/#{snippet.id}", user), code: 'updated code' expect(response).to have_http_status(200) @@ -606,7 +673,7 @@ describe API::API, api: true do expect(snippet.reload.content).to eq('updated code') end - it 'should update an existing project snippet with new title' do + it 'updates an existing project snippet with new title' do put api("/projects/#{project.id}/snippets/#{snippet.id}", user), title: 'other api test' expect(response).to have_http_status(200) @@ -617,103 +684,31 @@ describe API::API, api: true do describe 'DELETE /projects/:id/snippets/:snippet_id' do before { snippet } - it 'should delete existing project snippet' do + it 'deletes existing project snippet' do expect do delete api("/projects/#{project.id}/snippets/#{snippet.id}", user) end.to change { Snippet.count }.by(-1) expect(response).to have_http_status(200) end - it 'should return 404 when deleting unknown snippet id' do + it 'returns 404 when deleting unknown snippet id' do delete api("/projects/#{project.id}/snippets/1234", user) expect(response).to have_http_status(404) end end describe 'GET /projects/:id/snippets/:snippet_id/raw' do - it 'should get a raw project snippet' do + it 'gets a raw project snippet' do get api("/projects/#{project.id}/snippets/#{snippet.id}/raw", user) expect(response).to have_http_status(200) end - it 'should return a 404 error if raw project snippet not found' do + it 'returns a 404 error if raw project snippet not found' do get api("/projects/#{project.id}/snippets/5555/raw", user) expect(response).to have_http_status(404) end end - describe :deploy_keys do - let(:deploy_keys_project) { create(:deploy_keys_project, project: project) } - let(:deploy_key) { deploy_keys_project.deploy_key } - - describe 'GET /projects/:id/deploy_keys' do - before { deploy_key } - - it 'should return array of ssh keys' do - get api("/projects/#{project.id}/deploy_keys", user) - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.first['title']).to eq(deploy_key.title) - end - end - - describe 'GET /projects/:id/deploy_keys/:key_id' do - it 'should return a single key' do - get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user) - expect(response).to have_http_status(200) - expect(json_response['title']).to eq(deploy_key.title) - end - - it 'should return 404 Not Found with invalid ID' do - get api("/projects/#{project.id}/deploy_keys/404", user) - expect(response).to have_http_status(404) - end - end - - describe 'POST /projects/:id/deploy_keys' do - it 'should not create an invalid ssh key' do - post api("/projects/#{project.id}/deploy_keys", user), { title: 'invalid key' } - expect(response).to have_http_status(400) - expect(json_response['message']['key']).to eq([ - 'can\'t be blank', - 'is too short (minimum is 0 characters)', - 'is invalid' - ]) - end - - it 'should not create a key without title' do - post api("/projects/#{project.id}/deploy_keys", user), key: 'some key' - expect(response).to have_http_status(400) - expect(json_response['message']['title']).to eq([ - 'can\'t be blank', - 'is too short (minimum is 0 characters)' - ]) - end - - it 'should create new ssh key' do - key_attrs = attributes_for :key - expect do - post api("/projects/#{project.id}/deploy_keys", user), key_attrs - end.to change{ project.deploy_keys.count }.by(1) - end - end - - describe 'DELETE /projects/:id/deploy_keys/:key_id' do - before { deploy_key } - - it 'should delete existing key' do - expect do - delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user) - end.to change{ project.deploy_keys.count }.by(-1) - end - - it 'should return 404 Not Found with invalid ID' do - delete api("/projects/#{project.id}/deploy_keys/404", user) - expect(response).to have_http_status(404) - end - end - end - describe :fork_admin do let(:project_fork_target) { create(:project) } let(:project_fork_source) { create(:project, :public) } @@ -721,12 +716,12 @@ describe API::API, api: true do describe 'POST /projects/:id/fork/:forked_from_id' do let(:new_project_fork_source) { create(:project, :public) } - it "shouldn't available for non admin users" do + it "is not available for non admin users" do post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", user) expect(response).to have_http_status(403) end - it 'should allow project to be forked from an existing project' do + it 'allows project to be forked from an existing project' do expect(project_fork_target.forked?).not_to be_truthy post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin) expect(response).to have_http_status(201) @@ -736,12 +731,12 @@ describe API::API, api: true do expect(project_fork_target.forked?).to be_truthy end - it 'should fail if forked_from project which does not exist' do + it 'fails if forked_from project which does not exist' do post api("/projects/#{project_fork_target.id}/fork/9999", admin) expect(response).to have_http_status(404) end - it 'should fail with 409 if already forked' do + it 'fails with 409 if already forked' do post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin) project_fork_target.reload expect(project_fork_target.forked_from_project.id).to eq(project_fork_source.id) @@ -754,7 +749,7 @@ describe API::API, api: true do end describe 'DELETE /projects/:id/fork' do - it "shouldn't be visible to users outside group" do + it "is not visible to users outside group" do delete api("/projects/#{project_fork_target.id}/fork", user) expect(response).to have_http_status(404) end @@ -767,12 +762,12 @@ describe API::API, api: true do project_fork_target.group.add_developer user2 end - it 'should be forbidden to non-owner users' do + it 'is forbidden to non-owner users' do delete api("/projects/#{project_fork_target.id}/fork", user2) expect(response).to have_http_status(403) end - it 'should make forked project unforked' do + it 'makes forked project unforked' do post api("/projects/#{project_fork_target.id}/fork/#{project_fork_source.id}", admin) project_fork_target.reload expect(project_fork_target.forked_from_project).not_to be_nil @@ -784,7 +779,7 @@ describe API::API, api: true do expect(project_fork_target.forked?).not_to be_truthy end - it 'should be idempotent if not forked' do + it 'is idempotent if not forked' do expect(project_fork_target.forked_from_project).to be_nil delete api("/projects/#{project_fork_target.id}/fork", admin) expect(response).to have_http_status(200) @@ -797,33 +792,50 @@ describe API::API, api: true do describe "POST /projects/:id/share" do let(:group) { create(:group) } - it "should share project with group" do + it "shares project with group" do + expires_at = 10.days.from_now.to_date + expect do - post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER, expires_at: expires_at end.to change { ProjectGroupLink.count }.by(1) expect(response.status).to eq 201 - expect(json_response['group_id']).to eq group.id - expect(json_response['group_access']).to eq Gitlab::Access::DEVELOPER + expect(json_response['group_id']).to eq(group.id) + expect(json_response['group_access']).to eq(Gitlab::Access::DEVELOPER) + expect(json_response['expires_at']).to eq(expires_at.to_s) end - it "should return a 400 error when group id is not given" do + it "returns a 400 error when group id is not given" do post api("/projects/#{project.id}/share", user), group_access: Gitlab::Access::DEVELOPER expect(response.status).to eq 400 end - it "should return a 400 error when access level is not given" do + it "returns a 400 error when access level is not given" do post api("/projects/#{project.id}/share", user), group_id: group.id expect(response.status).to eq 400 end - it "should return a 400 error when sharing is disabled" do + it "returns a 400 error when sharing is disabled" do project.namespace.update(share_with_group_lock: true) post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER expect(response.status).to eq 400 end - it "should return a 409 error when wrong params passed" do + it 'returns a 404 error when user cannot read group' do + private_group = create(:group, :private) + + post api("/projects/#{project.id}/share", user), group_id: private_group.id, group_access: Gitlab::Access::DEVELOPER + + expect(response.status).to eq 404 + end + + it 'returns a 404 error when group does not exist' do + post api("/projects/#{project.id}/share", user), group_id: 1234, group_access: Gitlab::Access::DEVELOPER + + expect(response.status).to eq 404 + end + + it "returns a 409 error when wrong params passed" do post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: 1234 expect(response.status).to eq 409 expect(json_response['message']).to eq 'Group access is not included in the list' @@ -843,14 +855,14 @@ describe API::API, api: true do let!(:unfound_public) { create(:empty_project, :public, name: 'unfound public') } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api("/projects/search/#{query}") expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should return an array of projects' do + it 'returns an array of projects' do get api("/projects/search/#{query}", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -860,7 +872,7 @@ describe API::API, api: true do end context 'when authenticated as a different user' do - it 'should return matching public projects' do + it 'returns matching public projects' do get api("/projects/search/#{query}", user2) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -881,7 +893,7 @@ describe API::API, api: true do before { project_member2 } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do project_param = { name: 'bar' } put api("/projects/#{project.id}"), project_param expect(response).to have_http_status(401) @@ -889,7 +901,7 @@ describe API::API, api: true do end context 'when authenticated as project owner' do - it 'should update name' do + it 'updates name' do project_param = { name: 'bar' } put api("/projects/#{project.id}", user), project_param expect(response).to have_http_status(200) @@ -898,7 +910,7 @@ describe API::API, api: true do end end - it 'should update visibility_level' do + it 'updates visibility_level' do project_param = { visibility_level: 20 } put api("/projects/#{project3.id}", user), project_param expect(response).to have_http_status(200) @@ -907,7 +919,7 @@ describe API::API, api: true do end end - it 'should update visibility_level from public to private' do + it 'updates visibility_level from public to private' do project3.update_attributes({ visibility_level: Gitlab::VisibilityLevel::PUBLIC }) project_param = { public: false } @@ -919,14 +931,23 @@ describe API::API, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end - it 'should not update name to existing name' do + it 'does not update name to existing name' do project_param = { name: project3.name } put api("/projects/#{project.id}", user), project_param expect(response).to have_http_status(400) expect(json_response['message']['name']).to eq(['has already been taken']) end - it 'should update path & name to existing path & name in different namespace' do + it 'updates request_access_enabled' do + project_param = { request_access_enabled: false } + + put api("/projects/#{project.id}", user), project_param + + expect(response).to have_http_status(200) + expect(json_response['request_access_enabled']).to eq(false) + end + + it 'updates path & name to existing path & name in different namespace' do project_param = { path: project4.path, name: project4.name } put api("/projects/#{project3.id}", user), project_param expect(response).to have_http_status(200) @@ -937,7 +958,7 @@ describe API::API, api: true do end context 'when authenticated as project master' do - it 'should update path' do + it 'updates path' do project_param = { path: 'bar' } put api("/projects/#{project3.id}", user4), project_param expect(response).to have_http_status(200) @@ -946,7 +967,7 @@ describe API::API, api: true do end end - it 'should update other attributes' do + it 'updates other attributes' do project_param = { issues_enabled: true, wiki_enabled: true, snippets_enabled: true, @@ -960,20 +981,20 @@ describe API::API, api: true do end end - it 'should not update path to existing path' do + it 'does not update path to existing path' do project_param = { path: project.path } put api("/projects/#{project3.id}", user4), project_param expect(response).to have_http_status(400) expect(json_response['message']['path']).to eq(['has already been taken']) end - it 'should not update name' do + it 'does not update name' do project_param = { name: 'bar' } put api("/projects/#{project3.id}", user4), project_param expect(response).to have_http_status(403) end - it 'should not update visibility_level' do + it 'does not update visibility_level' do project_param = { visibility_level: 20 } put api("/projects/#{project3.id}", user4), project_param expect(response).to have_http_status(403) @@ -981,13 +1002,14 @@ describe API::API, api: true do end context 'when authenticated as project developer' do - it 'should not update other attributes' do + it 'does not update other attributes' do project_param = { path: 'bar', issues_enabled: true, wiki_enabled: true, snippets_enabled: true, merge_requests_enabled: true, - description: 'new description' } + description: 'new description', + request_access_enabled: true } put api("/projects/#{project.id}", user3), project_param expect(response).to have_http_status(403) end @@ -1116,36 +1138,36 @@ describe API::API, api: true do describe 'DELETE /projects/:id' do context 'when authenticated as user' do - it 'should remove project' do + it 'removes project' do delete api("/projects/#{project.id}", user) expect(response).to have_http_status(200) end - it 'should not remove a project if not an owner' do + it 'does not remove a project if not an owner' do user3 = create(:user) project.team << [user3, :developer] delete api("/projects/#{project.id}", user3) expect(response).to have_http_status(403) end - it 'should not remove a non existing project' do + it 'does not remove a non existing project' do delete api('/projects/1328', user) expect(response).to have_http_status(404) end - it 'should not remove a project not attached to user' do + it 'does not remove a project not attached to user' do delete api("/projects/#{project.id}", user2) expect(response).to have_http_status(404) end end context 'when authenticated as admin' do - it 'should remove any existing project' do + it 'removes any existing project' do delete api("/projects/#{project.id}", admin) expect(response).to have_http_status(200) end - it 'should not remove a non existing project' do + it 'does not remove a non existing project' do delete api('/projects/1328', admin) expect(response).to have_http_status(404) end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 5890e9c9d3d..c4dc2d9006a 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -16,17 +16,17 @@ describe API::API, api: true do context "authorized user" do before { project.team << [user2, :reporter] } - it "should return project commits" do + it "returns project commits" do get api("/projects/#{project.id}/repository/tree", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array - expect(json_response.first['name']).to eq('encoding') + expect(json_response.first['name']).to eq('bar') expect(json_response.first['type']).to eq('tree') expect(json_response.first['mode']).to eq('040000') end - it 'should return a 404 for unknown ref' do + it 'returns a 404 for unknown ref' do get api("/projects/#{project.id}/repository/tree?ref_name=foo", user) expect(response).to have_http_status(404) @@ -36,7 +36,7 @@ describe API::API, api: true do end context "unauthorized user" do - it "should not return project commits" do + it "does not return project commits" do get api("/projects/#{project.id}/repository/tree") expect(response).to have_http_status(401) end @@ -44,41 +44,41 @@ describe API::API, api: true do end describe "GET /projects/:id/repository/blobs/:sha" do - it "should get the raw file contents" do + it "gets the raw file contents" do get api("/projects/#{project.id}/repository/blobs/master?filepath=README.md", user) expect(response).to have_http_status(200) end - it "should return 404 for invalid branch_name" do + it "returns 404 for invalid branch_name" do get api("/projects/#{project.id}/repository/blobs/invalid_branch_name?filepath=README.md", user) expect(response).to have_http_status(404) end - it "should return 404 for invalid file" do + it "returns 404 for invalid file" do get api("/projects/#{project.id}/repository/blobs/master?filepath=README.invalid", user) expect(response).to have_http_status(404) end - it "should return a 400 error if filepath is missing" do + it "returns a 400 error if filepath is missing" do get api("/projects/#{project.id}/repository/blobs/master", user) expect(response).to have_http_status(400) end end describe "GET /projects/:id/repository/commits/:sha/blob" do - it "should get the raw file contents" do + it "gets the raw file contents" do get api("/projects/#{project.id}/repository/commits/master/blob?filepath=README.md", user) expect(response).to have_http_status(200) end end describe "GET /projects/:id/repository/raw_blobs/:sha" do - it "should get the raw file contents" do + it "gets the raw file contents" do get api("/projects/#{project.id}/repository/raw_blobs/#{sample_blob.oid}", user) expect(response).to have_http_status(200) end - it 'should return a 404 for unknown blob' do + it 'returns a 404 for unknown blob' do get api("/projects/#{project.id}/repository/raw_blobs/123456", user) expect(response).to have_http_status(404) @@ -88,7 +88,7 @@ describe API::API, api: true do end describe "GET /projects/:id/repository/archive(.:format)?:sha" do - it "should get the archive" do + it "gets the archive" do get api("/projects/#{project.id}/repository/archive", user) repo_name = project.repository.name.gsub("\.git", "") expect(response).to have_http_status(200) @@ -97,7 +97,7 @@ describe API::API, api: true do expect(params['ArchivePath']).to match(/#{repo_name}\-[^\.]+\.tar.gz/) end - it "should get the archive.zip" do + it "gets the archive.zip" do get api("/projects/#{project.id}/repository/archive.zip", user) repo_name = project.repository.name.gsub("\.git", "") expect(response).to have_http_status(200) @@ -106,7 +106,7 @@ describe API::API, api: true do expect(params['ArchivePath']).to match(/#{repo_name}\-[^\.]+\.zip/) end - it "should get the archive.tar.bz2" do + it "gets the archive.tar.bz2" do get api("/projects/#{project.id}/repository/archive.tar.bz2", user) repo_name = project.repository.name.gsub("\.git", "") expect(response).to have_http_status(200) @@ -115,28 +115,28 @@ describe API::API, api: true do expect(params['ArchivePath']).to match(/#{repo_name}\-[^\.]+\.tar.bz2/) end - it "should return 404 for invalid sha" do + it "returns 404 for invalid sha" do get api("/projects/#{project.id}/repository/archive/?sha=xxx", user) expect(response).to have_http_status(404) end end describe 'GET /projects/:id/repository/compare' do - it "should compare branches" do + it "compares branches" do get api("/projects/#{project.id}/repository/compare", user), from: 'master', to: 'feature' expect(response).to have_http_status(200) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present end - it "should compare tags" do + it "compares tags" do get api("/projects/#{project.id}/repository/compare", user), from: 'v1.0.0', to: 'v1.1.0' expect(response).to have_http_status(200) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present end - it "should compare commits" do + it "compares commits" do get api("/projects/#{project.id}/repository/compare", user), from: sample_commit.id, to: sample_commit.parent_id expect(response).to have_http_status(200) expect(json_response['commits']).to be_empty @@ -144,14 +144,14 @@ describe API::API, api: true do expect(json_response['compare_same_ref']).to be_falsey end - it "should compare commits in reverse order" do + it "compares commits in reverse order" do get api("/projects/#{project.id}/repository/compare", user), from: sample_commit.parent_id, to: sample_commit.id expect(response).to have_http_status(200) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present end - it "should compare same refs" do + it "compares same refs" do get api("/projects/#{project.id}/repository/compare", user), from: 'master', to: 'master' expect(response).to have_http_status(200) expect(json_response['commits']).to be_empty @@ -161,14 +161,14 @@ describe API::API, api: true do end describe 'GET /projects/:id/repository/contributors' do - it 'should return valid data' do + it 'returns valid data' do get api("/projects/#{project.id}/repository/contributors", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array contributor = json_response.first - expect(contributor['email']).to eq('dmitriy.zaporozhets@gmail.com') - expect(contributor['name']).to eq('Dmitriy Zaporozhets') - expect(contributor['commits']).to eq(13) + expect(contributor['email']).to eq('tiagonbotelho@hotmail.com') + expect(contributor['name']).to eq('tiagonbotelho') + expect(contributor['commits']).to eq(1) expect(contributor['additions']).to eq(0) expect(contributor['deletions']).to eq(0) end diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index 00a3c917b6a..f46f016135e 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -35,7 +35,7 @@ describe API::Runners, api: true do describe 'GET /runners' do context 'authorized user' do - it 'should return user available runners' do + it 'returns user available runners' do get api('/runners', user) shared = json_response.any?{ |r| r['is_shared'] } @@ -44,7 +44,7 @@ describe API::Runners, api: true do expect(shared).to be_falsey end - it 'should filter runners by scope' do + it 'filters runners by scope' do get api('/runners?scope=active', user) shared = json_response.any?{ |r| r['is_shared'] } @@ -53,14 +53,14 @@ describe API::Runners, api: true do expect(shared).to be_falsey end - it 'should avoid filtering if scope is invalid' do + it 'avoids filtering if scope is invalid' do get api('/runners?scope=unknown', user) expect(response).to have_http_status(400) end end context 'unauthorized user' do - it 'should not return runners' do + it 'does not return runners' do get api('/runners') expect(response).to have_http_status(401) @@ -71,7 +71,7 @@ describe API::Runners, api: true do describe 'GET /runners/all' do context 'authorized user' do context 'with admin privileges' do - it 'should return all runners' do + it 'returns all runners' do get api('/runners/all', admin) shared = json_response.any?{ |r| r['is_shared'] } @@ -82,14 +82,14 @@ describe API::Runners, api: true do end context 'without admin privileges' do - it 'should not return runners list' do + it 'does not return runners list' do get api('/runners/all', user) expect(response).to have_http_status(403) end end - it 'should filter runners by scope' do + it 'filters runners by scope' do get api('/runners/all?scope=specific', admin) shared = json_response.any?{ |r| r['is_shared'] } @@ -98,14 +98,14 @@ describe API::Runners, api: true do expect(shared).to be_falsey end - it 'should avoid filtering if scope is invalid' do + it 'avoids filtering if scope is invalid' do get api('/runners?scope=unknown', admin) expect(response).to have_http_status(400) end end context 'unauthorized user' do - it 'should not return runners' do + it 'does not return runners' do get api('/runners') expect(response).to have_http_status(401) @@ -116,7 +116,7 @@ describe API::Runners, api: true do describe 'GET /runners/:id' do context 'admin user' do context 'when runner is shared' do - it "should return runner's details" do + it "returns runner's details" do get api("/runners/#{shared_runner.id}", admin) expect(response).to have_http_status(200) @@ -125,7 +125,7 @@ describe API::Runners, api: true do end context 'when runner is not shared' do - it "should return runner's details" do + it "returns runner's details" do get api("/runners/#{specific_runner.id}", admin) expect(response).to have_http_status(200) @@ -133,7 +133,7 @@ describe API::Runners, api: true do end end - it 'should return 404 if runner does not exists' do + it 'returns 404 if runner does not exists' do get api('/runners/9999', admin) expect(response).to have_http_status(404) @@ -142,7 +142,7 @@ describe API::Runners, api: true do context "runner project's administrative user" do context 'when runner is not shared' do - it "should return runner's details" do + it "returns runner's details" do get api("/runners/#{specific_runner.id}", user) expect(response).to have_http_status(200) @@ -151,7 +151,7 @@ describe API::Runners, api: true do end context 'when runner is shared' do - it "should return runner's details" do + it "returns runner's details" do get api("/runners/#{shared_runner.id}", user) expect(response).to have_http_status(200) @@ -161,7 +161,7 @@ describe API::Runners, api: true do end context 'other authorized user' do - it "should not return runner's details" do + it "does not return runner's details" do get api("/runners/#{specific_runner.id}", user2) expect(response).to have_http_status(403) @@ -169,7 +169,7 @@ describe API::Runners, api: true do end context 'unauthorized user' do - it "should not return runner's details" do + it "does not return runner's details" do get api("/runners/#{specific_runner.id}") expect(response).to have_http_status(401) @@ -180,7 +180,7 @@ describe API::Runners, api: true do describe 'PUT /runners/:id' do context 'admin user' do context 'when runner is shared' do - it 'should update runner' do + it 'updates runner' do description = shared_runner.description active = shared_runner.active @@ -201,7 +201,7 @@ describe API::Runners, api: true do end context 'when runner is not shared' do - it 'should update runner' do + it 'updates runner' do description = specific_runner.description update_runner(specific_runner.id, admin, description: 'test') specific_runner.reload @@ -212,7 +212,7 @@ describe API::Runners, api: true do end end - it 'should return 404 if runner does not exists' do + it 'returns 404 if runner does not exists' do update_runner(9999, admin, description: 'test') expect(response).to have_http_status(404) @@ -225,7 +225,7 @@ describe API::Runners, api: true do context 'authorized user' do context 'when runner is shared' do - it 'should not update runner' do + it 'does not update runner' do put api("/runners/#{shared_runner.id}", user) expect(response).to have_http_status(403) @@ -233,13 +233,13 @@ describe API::Runners, api: true do end context 'when runner is not shared' do - it 'should not update runner without access to it' do + it 'does not update runner without access to it' do put api("/runners/#{specific_runner.id}", user2) expect(response).to have_http_status(403) end - it 'should update runner with access to it' do + it 'updates runner with access to it' do description = specific_runner.description put api("/runners/#{specific_runner.id}", admin), description: 'test' specific_runner.reload @@ -252,7 +252,7 @@ describe API::Runners, api: true do end context 'unauthorized user' do - it 'should not delete runner' do + it 'does not delete runner' do put api("/runners/#{specific_runner.id}") expect(response).to have_http_status(401) @@ -263,7 +263,7 @@ describe API::Runners, api: true do describe 'DELETE /runners/:id' do context 'admin user' do context 'when runner is shared' do - it 'should delete runner' do + it 'deletes runner' do expect do delete api("/runners/#{shared_runner.id}", admin) end.to change{ Ci::Runner.shared.count }.by(-1) @@ -272,14 +272,14 @@ describe API::Runners, api: true do end context 'when runner is not shared' do - it 'should delete unused runner' do + it 'deletes unused runner' do expect do delete api("/runners/#{unused_specific_runner.id}", admin) end.to change{ Ci::Runner.specific.count }.by(-1) expect(response).to have_http_status(200) end - it 'should delete used runner' do + it 'deletes used runner' do expect do delete api("/runners/#{specific_runner.id}", admin) end.to change{ Ci::Runner.specific.count }.by(-1) @@ -287,7 +287,7 @@ describe API::Runners, api: true do end end - it 'should return 404 if runner does not exists' do + it 'returns 404 if runner does not exists' do delete api('/runners/9999', admin) expect(response).to have_http_status(404) @@ -296,24 +296,24 @@ describe API::Runners, api: true do context 'authorized user' do context 'when runner is shared' do - it 'should not delete runner' do + it 'does not delete runner' do delete api("/runners/#{shared_runner.id}", user) expect(response).to have_http_status(403) end end context 'when runner is not shared' do - it 'should not delete runner without access to it' do + it 'does not delete runner without access to it' do delete api("/runners/#{specific_runner.id}", user2) expect(response).to have_http_status(403) end - it 'should not delete runner with more than one associated project' do + it 'does not delete runner with more than one associated project' do delete api("/runners/#{two_projects_runner.id}", user) expect(response).to have_http_status(403) end - it 'should delete runner for one owned project' do + it 'deletes runner for one owned project' do expect do delete api("/runners/#{specific_runner.id}", user) end.to change{ Ci::Runner.specific.count }.by(-1) @@ -323,7 +323,7 @@ describe API::Runners, api: true do end context 'unauthorized user' do - it 'should not delete runner' do + it 'does not delete runner' do delete api("/runners/#{specific_runner.id}") expect(response).to have_http_status(401) @@ -333,7 +333,7 @@ describe API::Runners, api: true do describe 'GET /projects/:id/runners' do context 'authorized user with master privileges' do - it "should return project's runners" do + it "returns project's runners" do get api("/projects/#{project.id}/runners", user) shared = json_response.any?{ |r| r['is_shared'] } @@ -344,7 +344,7 @@ describe API::Runners, api: true do end context 'authorized user without master privileges' do - it "should not return project's runners" do + it "does not return project's runners" do get api("/projects/#{project.id}/runners", user2) expect(response).to have_http_status(403) @@ -352,7 +352,7 @@ describe API::Runners, api: true do end context 'unauthorized user' do - it "should not return project's runners" do + it "does not return project's runners" do get api("/projects/#{project.id}/runners") expect(response).to have_http_status(401) @@ -368,21 +368,21 @@ describe API::Runners, api: true do end end - it 'should enable specific runner' do + it 'enables specific runner' do expect do post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id end.to change{ project.runners.count }.by(+1) expect(response).to have_http_status(201) end - it 'should avoid changes when enabling already enabled runner' do + it 'avoids changes when enabling already enabled runner' do expect do post api("/projects/#{project.id}/runners", user), runner_id: specific_runner.id end.to change{ project.runners.count }.by(0) expect(response).to have_http_status(409) end - it 'should not enable locked runner' do + it 'does not enable locked runner' do specific_runner2.update(locked: true) expect do @@ -392,14 +392,14 @@ describe API::Runners, api: true do expect(response).to have_http_status(403) end - it 'should not enable shared runner' do + it 'does not enable shared runner' do post api("/projects/#{project.id}/runners", user), runner_id: shared_runner.id expect(response).to have_http_status(403) end context 'user is admin' do - it 'should enable any specific runner' do + it 'enables any specific runner' do expect do post api("/projects/#{project.id}/runners", admin), runner_id: unused_specific_runner.id end.to change{ project.runners.count }.by(+1) @@ -408,14 +408,14 @@ describe API::Runners, api: true do end context 'user is not admin' do - it 'should not enable runner without access to' do + it 'does not enable runner without access to' do post api("/projects/#{project.id}/runners", user), runner_id: unused_specific_runner.id expect(response).to have_http_status(403) end end - it 'should raise an error when no runner_id param is provided' do + it 'raises an error when no runner_id param is provided' do post api("/projects/#{project.id}/runners", admin) expect(response).to have_http_status(400) @@ -423,7 +423,7 @@ describe API::Runners, api: true do end context 'authorized user without permissions' do - it 'should not enable runner' do + it 'does not enable runner' do post api("/projects/#{project.id}/runners", user2) expect(response).to have_http_status(403) @@ -431,7 +431,7 @@ describe API::Runners, api: true do end context 'unauthorized user' do - it 'should not enable runner' do + it 'does not enable runner' do post api("/projects/#{project.id}/runners") expect(response).to have_http_status(401) @@ -442,7 +442,7 @@ describe API::Runners, api: true do describe 'DELETE /projects/:id/runners/:runner_id' do context 'authorized user' do context 'when runner have more than one associated projects' do - it "should disable project's runner" do + it "disables project's runner" do expect do delete api("/projects/#{project.id}/runners/#{two_projects_runner.id}", user) end.to change{ project.runners.count }.by(-1) @@ -451,7 +451,7 @@ describe API::Runners, api: true do end context 'when runner have one associated projects' do - it "should not disable project's runner" do + it "does not disable project's runner" do expect do delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user) end.to change{ project.runners.count }.by(0) @@ -459,7 +459,7 @@ describe API::Runners, api: true do end end - it 'should return 404 is runner is not found' do + it 'returns 404 is runner is not found' do delete api("/projects/#{project.id}/runners/9999", user) expect(response).to have_http_status(404) @@ -467,7 +467,7 @@ describe API::Runners, api: true do end context 'authorized user without permissions' do - it "should not disable project's runner" do + it "does not disable project's runner" do delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user2) expect(response).to have_http_status(403) @@ -475,7 +475,7 @@ describe API::Runners, api: true do end context 'unauthorized user' do - it "should not disable project's runner" do + it "does not disable project's runner" do delete api("/projects/#{project.id}/runners/#{specific_runner.id}") expect(response).to have_http_status(401) diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index a2446e12804..375671bca4c 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -11,13 +11,13 @@ describe API::API, api: true do describe "PUT /projects/:id/services/#{service.dasherize}" do include_context service - it "should update #{service} settings" do + it "updates #{service} settings" do put api("/projects/#{project.id}/services/#{dashed_service}", user), service_attrs expect(response).to have_http_status(200) end - it "should return if required fields missing" do + it "returns if required fields missing" do attrs = service_attrs required_attributes = service_attrs_list.select do |attr| @@ -32,7 +32,7 @@ describe API::API, api: true do attrs.delete(required_attributes.sample) expected_code = 400 end - + put api("/projects/#{project.id}/services/#{dashed_service}", user), attrs expect(response.status).to eq(expected_code) @@ -42,7 +42,7 @@ describe API::API, api: true do describe "DELETE /projects/:id/services/#{service.dasherize}" do include_context service - it "should delete #{service}" do + it "deletes #{service}" do delete api("/projects/#{project.id}/services/#{dashed_service}", user) expect(response).to have_http_status(200) @@ -62,29 +62,29 @@ describe API::API, api: true do service_object.save end - it 'should return authentication error when unauthenticated' do + it 'returns authentication error when unauthenticated' do get api("/projects/#{project.id}/services/#{dashed_service}") expect(response).to have_http_status(401) end - - it "should return all properties of service #{service} when authenticated as admin" do + + it "returns all properties of service #{service} when authenticated as admin" do get api("/projects/#{project.id}/services/#{dashed_service}", admin) - + expect(response).to have_http_status(200) expect(json_response['properties'].keys.map(&:to_sym)).to match_array(service_attrs_list.map) end - it "should return properties of service #{service} other than passwords when authenticated as project owner" do + it "returns properties of service #{service} other than passwords when authenticated as project owner" do get api("/projects/#{project.id}/services/#{dashed_service}", user) expect(response).to have_http_status(200) expect(json_response['properties'].keys.map(&:to_sym)).to match_array(service_attrs_list_without_passwords) end - it "should return error when authenticated but not a project owner" do + it "returns error when authenticated but not a project owner" do project.team << [user2, :developer] get api("/projects/#{project.id}/services/#{dashed_service}", user2) - + expect(response).to have_http_status(403) end end diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb index c15b7ff9792..acad1365ace 100644 --- a/spec/requests/api/session_spec.rb +++ b/spec/requests/api/session_spec.rb @@ -7,7 +7,7 @@ describe API::API, api: true do describe "POST /session" do context "when valid password" do - it "should return private token" do + it "returns private token" do post api("/session"), email: user.email, password: '12345678' expect(response).to have_http_status(201) @@ -17,10 +17,21 @@ describe API::API, api: true do expect(json_response['can_create_project']).to eq(user.can_create_project?) expect(json_response['can_create_group']).to eq(user.can_create_group?) end + + context 'with 2FA enabled' do + it 'rejects sign in attempts' do + user = create(:user, :two_factor) + + post api('/session'), email: user.email, password: user.password + + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled.') + end + end end context 'when email has case-typo and password is valid' do - it 'should return private token' do + it 'returns private token' do post api('/session'), email: user.email.upcase, password: '12345678' expect(response.status).to eq 201 @@ -33,7 +44,7 @@ describe API::API, api: true do end context 'when login has case-typo and password is valid' do - it 'should return private token' do + it 'returns private token' do post api('/session'), login: user.username.upcase, password: '12345678' expect(response.status).to eq 201 @@ -46,7 +57,7 @@ describe API::API, api: true do end context "when invalid password" do - it "should return authentication error" do + it "returns authentication error" do post api("/session"), email: user.email, password: '123' expect(response).to have_http_status(401) @@ -56,7 +67,7 @@ describe API::API, api: true do end context "when empty password" do - it "should return authentication error" do + it "returns authentication error" do post api("/session"), email: user.email expect(response).to have_http_status(401) @@ -66,7 +77,7 @@ describe API::API, api: true do end context "when empty name" do - it "should return authentication error" do + it "returns authentication error" do post api("/session"), password: user.password expect(response).to have_http_status(401) diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 684c2cd8e24..f4903d8e0be 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -7,29 +7,45 @@ describe API::API, 'Settings', api: true do let(:admin) { create(:admin) } describe "GET /application/settings" do - it "should return application settings" do + it "returns application settings" do get api("/application/settings", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Hash expect(json_response['default_projects_limit']).to eq(42) expect(json_response['signin_enabled']).to be_truthy expect(json_response['repository_storage']).to eq('default') + expect(json_response['koding_enabled']).to be_falsey + expect(json_response['koding_url']).to be_nil end end describe "PUT /application/settings" do - before do - storages = { 'custom' => 'tmp/tests/custom_repositories' } - allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) + context "custom repository storage type set in the config" do + before do + storages = { 'custom' => 'tmp/tests/custom_repositories' } + allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) + end + + 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' + expect(response).to have_http_status(200) + expect(json_response['default_projects_limit']).to eq(3) + expect(json_response['signin_enabled']).to be_falsey + expect(json_response['repository_storage']).to eq('custom') + expect(json_response['koding_enabled']).to be_truthy + expect(json_response['koding_url']).to eq('http://koding.example.com') + end end - it "should update application settings" do - put api("/application/settings", admin), - default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom' - expect(response).to have_http_status(200) - expect(json_response['default_projects_limit']).to eq(3) - expect(json_response['signin_enabled']).to be_falsey - expect(json_response['repository_storage']).to eq('custom') + context "missing koding_url value when koding_enabled is true" do + it "returns a blank parameter error message" do + put api("/application/settings", admin), koding_enabled: true + + expect(response).to have_http_status(400) + expect(json_response['message']).to have_key('koding_url') + expect(json_response['message']['koding_url']).to include "can't be blank" + end end end end diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb index cf66f261ade..1ce2658569e 100644 --- a/spec/requests/api/system_hooks_spec.rb +++ b/spec/requests/api/system_hooks_spec.rb @@ -11,21 +11,21 @@ describe API::API, api: true do describe "GET /hooks" do context "when no user" do - it "should return authentication error" do + it "returns authentication error" do get api("/hooks") expect(response).to have_http_status(401) end end context "when not an admin" do - it "should return forbidden error" do + it "returns forbidden error" do get api("/hooks", user) expect(response).to have_http_status(403) end end context "when authenticated as admin" do - it "should return an array of hooks" do + it "returns an array of hooks" do get api("/hooks", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -35,18 +35,18 @@ describe API::API, api: true do end describe "POST /hooks" do - it "should create new hook" do + it "creates new hook" do expect do post api("/hooks", admin), url: 'http://example.com' end.to change { SystemHook.count }.by(1) end - it "should respond with 400 if url not given" do + it "responds with 400 if url not given" do post api("/hooks", admin) expect(response).to have_http_status(400) end - it "should not create new hook without url" do + it "does not create new hook without url" do expect do post api("/hooks", admin) end.not_to change { SystemHook.count } @@ -54,26 +54,26 @@ describe API::API, api: true do end describe "GET /hooks/:id" do - it "should return hook by id" do + it "returns hook by id" do get api("/hooks/#{hook.id}", admin) expect(response).to have_http_status(200) expect(json_response['event_name']).to eq('project_create') end - it "should return 404 on failure" do + it "returns 404 on failure" do get api("/hooks/404", admin) expect(response).to have_http_status(404) end end describe "DELETE /hooks/:id" do - it "should delete a hook" do + it "deletes a hook" do expect do delete api("/hooks/#{hook.id}", admin) end.to change { SystemHook.count }.by(-1) end - it "should return success if hook id not found" do + it "returns success if hook id not found" do delete api("/hooks/12345", admin) expect(response).to have_http_status(200) end diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb index fa700ab7343..d563883cd47 100644 --- a/spec/requests/api/tags_spec.rb +++ b/spec/requests/api/tags_spec.rb @@ -16,7 +16,7 @@ describe API::API, api: true do let(:description) { 'Awesome release!' } context 'without releases' do - it "should return an array of project tags" do + it "returns an array of project tags" do get api("/projects/#{project.id}/repository/tags", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -30,7 +30,7 @@ describe API::API, api: true do release.update_attributes(description: description) end - it "should return an array of project tags with release info" do + it "returns an array of project tags with release info" do get api("/projects/#{project.id}/repository/tags", user) expect(response).to have_http_status(200) @@ -61,7 +61,7 @@ describe API::API, api: true do describe 'POST /projects/:id/repository/tags' do context 'lightweight tags' do - it 'should create a new tag' do + it 'creates a new tag' do post api("/projects/#{project.id}/repository/tags", user), tag_name: 'v7.0.1', ref: 'master' @@ -72,7 +72,7 @@ describe API::API, api: true do end context 'lightweight tags with release notes' do - it 'should create a new tag' do + it 'creates a new tag' do post api("/projects/#{project.id}/repository/tags", user), tag_name: 'v7.0.1', ref: 'master', @@ -92,13 +92,13 @@ describe API::API, api: true do end context 'delete tag' do - it 'should delete an existing tag' do + it 'deletes an existing tag' do delete api("/projects/#{project.id}/repository/tags/#{tag_name}", user) expect(response).to have_http_status(200) expect(json_response['tag_name']).to eq(tag_name) end - it 'should raise 404 if the tag does not exist' do + it 'raises 404 if the tag does not exist' do delete api("/projects/#{project.id}/repository/tags/foobar", user) expect(response).to have_http_status(404) end @@ -106,7 +106,7 @@ describe API::API, api: true do end context 'annotated tag' do - it 'should create a new annotated tag' do + it 'creates a new annotated tag' do # Identity must be set in .gitconfig to create annotated tag. repo_path = project.repository.path_to_repo system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.name #{user.name})) @@ -123,14 +123,14 @@ describe API::API, api: true do end end - it 'should deny for user without push access' do + it 'denies for user without push access' do post api("/projects/#{project.id}/repository/tags", user2), tag_name: 'v1.9.0', ref: '621491c677087aa243f165eab467bfdfbee00be1' expect(response).to have_http_status(403) end - it 'should return 400 if tag name is invalid' do + it 'returns 400 if tag name is invalid' do post api("/projects/#{project.id}/repository/tags", user), tag_name: 'v 1.0.0', ref: 'master' @@ -138,7 +138,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('Tag name invalid') end - it 'should return 400 if tag already exists' do + it 'returns 400 if tag already exists' do post api("/projects/#{project.id}/repository/tags", user), tag_name: 'v8.0.0', ref: 'master' @@ -150,7 +150,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('Tag v8.0.0 already exists') end - it 'should return 400 if ref name is invalid' do + it 'returns 400 if ref name is invalid' do post api("/projects/#{project.id}/repository/tags", user), tag_name: 'mytag', ref: 'foo' @@ -163,7 +163,7 @@ describe API::API, api: true do let(:tag_name) { project.repository.tag_names.first } let(:description) { 'Awesome release!' } - it 'should create description for existing git tag' do + it 'creates description for existing git tag' do post api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), description: description @@ -172,7 +172,7 @@ describe API::API, api: true do expect(json_response['description']).to eq(description) end - it 'should return 404 if the tag does not exist' do + it 'returns 404 if the tag does not exist' do post api("/projects/#{project.id}/repository/tags/foobar/release", user), description: description @@ -186,7 +186,7 @@ describe API::API, api: true do release.update_attributes(description: description) end - it 'should return 409 if there is already a release' do + it 'returns 409 if there is already a release' do post api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), description: description @@ -207,7 +207,7 @@ describe API::API, api: true do release.update_attributes(description: description) end - it 'should update the release description' do + it 'updates the release description' do put api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), description: new_description @@ -217,7 +217,7 @@ describe API::API, api: true do end end - it 'should return 404 if the tag does not exist' do + it 'returns 404 if the tag does not exist' do put api("/projects/#{project.id}/repository/tags/foobar/release", user), description: new_description @@ -225,7 +225,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('Tag does not exist') end - it 'should return 404 if the release does not exist' do + it 'returns 404 if the release does not exist' do put api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), description: new_description diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index 68d0f41b489..d32ba60fc4c 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -3,50 +3,201 @@ require 'spec_helper' describe API::Templates, api: true do include ApiHelpers - describe 'the Template Entity' do - before { get api('/gitignores/Ruby') } + shared_examples_for 'the Template Entity' do |path| + before { get api(path) } it { expect(json_response['name']).to eq('Ruby') } it { expect(json_response['content']).to include('*.gem') } end - - describe 'the TemplateList Entity' do - before { get api('/gitignores') } + + shared_examples_for 'the TemplateList Entity' do |path| + before { get api(path) } it { expect(json_response.first['name']).not_to be_nil } it { expect(json_response.first['content']).to be_nil } end - context 'requesting gitignores' do - describe 'GET /gitignores' do - it 'returns a list of available gitignore templates' do - get api('/gitignores') + shared_examples_for 'requesting gitignores' do |path| + it 'returns a list of available gitignore templates' do + get api(path) - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.size).to be > 15 - end + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to be > 15 end end - context 'requesting gitlab-ci-ymls' do - describe 'GET /gitlab_ci_ymls' do - it 'returns a list of available gitlab_ci_ymls' do - get api('/gitlab_ci_ymls') + shared_examples_for 'requesting gitlab-ci-ymls' do |path| + it 'returns a list of available gitlab_ci_ymls' do + get api(path) - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.first['name']).not_to be_nil - end + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).not_to be_nil end end - describe 'GET /gitlab_ci_ymls/Ruby' do + shared_examples_for 'requesting gitlab-ci-yml for Ruby' do |path| it 'adds a disclaimer on the top' do - get api('/gitlab_ci_ymls/Ruby') + get api(path) expect(response).to have_http_status(200) expect(json_response['content']).to start_with("# This file is a template,") end end + + shared_examples_for 'the License Template Entity' do |path| + before { get api(path) } + + it 'returns a license template' do + expect(json_response['key']).to eq('mit') + expect(json_response['name']).to eq('MIT License') + expect(json_response['nickname']).to be_nil + expect(json_response['popular']).to be true + expect(json_response['html_url']).to eq('http://choosealicense.com/licenses/mit/') + expect(json_response['source_url']).to eq('https://opensource.org/licenses/MIT') + expect(json_response['description']).to include('A permissive license that is short and to the point.') + expect(json_response['conditions']).to eq(%w[include-copyright]) + expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) + expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['content']).to include('The MIT License (MIT)') + end + end + + shared_examples_for 'GET licenses' do |path| + it 'returns a list of available license templates' do + get api(path) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(15) + expect(json_response.map { |l| l['key'] }).to include('agpl-3.0') + end + + describe 'the popular parameter' do + context 'with popular=1' do + it 'returns a list of available popular license templates' do + get api("#{path}?popular=1") + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(3) + expect(json_response.map { |l| l['key'] }).to include('apache-2.0') + end + end + end + end + + shared_examples_for 'GET licenses/:name' do |path| + context 'with :project and :fullname given' do + before do + get api("#{path}/#{license_type}?project=My+Awesome+Project&fullname=Anton+#{license_type.upcase}") + end + + context 'for the mit license' do + let(:license_type) { 'mit' } + + it 'returns the license text' do + expect(json_response['content']).to include('The MIT License (MIT)') + end + + it 'replaces placeholder values' do + expect(json_response['content']).to include("Copyright (c) #{Time.now.year} Anton") + end + end + + context 'for the agpl-3.0 license' do + let(:license_type) { 'agpl-3.0' } + + it 'returns the license text' do + expect(json_response['content']).to include('GNU AFFERO GENERAL PUBLIC LICENSE') + end + + it 'replaces placeholder values' do + expect(json_response['content']).to include('My Awesome Project') + expect(json_response['content']).to include("Copyright (C) #{Time.now.year} Anton") + end + end + + context 'for the gpl-3.0 license' do + let(:license_type) { 'gpl-3.0' } + + it 'returns the license text' do + expect(json_response['content']).to include('GNU GENERAL PUBLIC LICENSE') + end + + it 'replaces placeholder values' do + expect(json_response['content']).to include('My Awesome Project') + expect(json_response['content']).to include("Copyright (C) #{Time.now.year} Anton") + end + end + + context 'for the gpl-2.0 license' do + let(:license_type) { 'gpl-2.0' } + + it 'returns the license text' do + expect(json_response['content']).to include('GNU GENERAL PUBLIC LICENSE') + end + + it 'replaces placeholder values' do + expect(json_response['content']).to include('My Awesome Project') + expect(json_response['content']).to include("Copyright (C) #{Time.now.year} Anton") + end + end + + context 'for the apache-2.0 license' do + let(:license_type) { 'apache-2.0' } + + it 'returns the license text' do + expect(json_response['content']).to include('Apache License') + end + + it 'replaces placeholder values' do + expect(json_response['content']).to include("Copyright #{Time.now.year} Anton") + end + end + + context 'for an uknown license' do + let(:license_type) { 'muth-over9000' } + + it 'returns a 404' do + expect(response).to have_http_status(404) + end + end + end + + context 'with no :fullname given' do + context 'with an authenticated user' do + let(:user) { create(:user) } + + it 'replaces the copyright owner placeholder with the name of the current user' do + get api('/templates/licenses/mit', user) + + expect(json_response['content']).to include("Copyright (c) #{Time.now.year} #{user.name}") + end + end + end + end + + describe 'with /templates namespace' do + it_behaves_like 'the Template Entity', '/templates/gitignores/Ruby' + it_behaves_like 'the TemplateList Entity', '/templates/gitignores' + it_behaves_like 'requesting gitignores', '/templates/gitignores' + it_behaves_like 'requesting gitlab-ci-ymls', '/templates/gitlab_ci_ymls' + it_behaves_like 'requesting gitlab-ci-yml for Ruby', '/templates/gitlab_ci_ymls/Ruby' + it_behaves_like 'the License Template Entity', '/templates/licenses/mit' + it_behaves_like 'GET licenses', '/templates/licenses' + it_behaves_like 'GET licenses/:name', '/templates/licenses' + end + + describe 'without /templates namespace' do + it_behaves_like 'the Template Entity', '/gitignores/Ruby' + it_behaves_like 'the TemplateList Entity', '/gitignores' + it_behaves_like 'requesting gitignores', '/gitignores' + it_behaves_like 'requesting gitlab-ci-ymls', '/gitlab_ci_ymls' + it_behaves_like 'requesting gitlab-ci-yml for Ruby', '/gitlab_ci_ymls/Ruby' + it_behaves_like 'the License Template Entity', '/licenses/mit' + it_behaves_like 'GET licenses', '/licenses' + it_behaves_like 'GET licenses/:name', '/licenses' + end end diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index 3ccd0af652f..887a2ba5b84 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -117,6 +117,12 @@ describe API::Todos, api: true do expect(response.status).to eq(200) expect(pending_1.reload).to be_done end + + it 'updates todos cache' do + expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original + + delete api("/todos/#{pending_1.id}", john_doe) + end end end @@ -139,6 +145,12 @@ describe API::Todos, api: true do expect(pending_2.reload).to be_done expect(pending_3.reload).to be_done end + + it 'updates todos cache' do + expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original + + delete api("/todos", john_doe) + end end end diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index 8992996c30a..82bba1ce8a4 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -27,17 +27,17 @@ describe API::API do end context 'Handles errors' do - it 'should return bad request if token is missing' do + it 'returns bad request if token is missing' do post api("/projects/#{project.id}/trigger/builds"), ref: 'master' expect(response).to have_http_status(400) end - it 'should return not found if project is not found' do + it 'returns not found if project is not found' do post api('/projects/0/trigger/builds'), options.merge(ref: 'master') expect(response).to have_http_status(404) end - it 'should return unauthorized if token is for different project' do + it 'returns unauthorized if token is for different project' do post api("/projects/#{project2.id}/trigger/builds"), options.merge(ref: 'master') expect(response).to have_http_status(401) end @@ -46,14 +46,15 @@ describe API::API do context 'Have a commit' do let(:pipeline) { project.pipelines.last } - it 'should create builds' do + it 'creates builds' do post api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'master') expect(response).to have_http_status(201) pipeline.builds.reload - expect(pipeline.builds.size).to eq(2) + expect(pipeline.builds.pending.size).to eq(2) + expect(pipeline.builds.size).to eq(5) end - it 'should return bad request with no builds created if there\'s no commit for that ref' do + it 'returns bad request with no builds created if there\'s no commit for that ref' do post api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'other-branch') expect(response).to have_http_status(400) expect(json_response['message']).to eq('No builds created') @@ -64,19 +65,19 @@ describe API::API do { 'TRIGGER_KEY' => 'TRIGGER_VALUE' } end - it 'should validate variables to be a hash' do + it 'validates variables to be a hash' do post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: 'value', ref: 'master') expect(response).to have_http_status(400) expect(json_response['message']).to eq('variables needs to be a hash') end - it 'should validate variables needs to be a map of key-valued strings' do + it 'validates variables needs to be a map of key-valued strings' do post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: { key: %w(1 2) }, ref: 'master') expect(response).to have_http_status(400) expect(json_response['message']).to eq('variables needs to be a map of key-valued strings') end - it 'create trigger request with variables' do + it 'creates trigger request with variables' do post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: variables, ref: 'master') expect(response).to have_http_status(201) pipeline.builds.reload @@ -88,7 +89,7 @@ describe API::API do describe 'GET /projects/:id/triggers' do context 'authenticated user with valid permissions' do - it 'should return list of triggers' do + it 'returns list of triggers' do get api("/projects/#{project.id}/triggers", user) expect(response).to have_http_status(200) @@ -98,7 +99,7 @@ describe API::API do end context 'authenticated user with invalid permissions' do - it 'should not return triggers list' do + it 'does not return triggers list' do get api("/projects/#{project.id}/triggers", user2) expect(response).to have_http_status(403) @@ -106,7 +107,7 @@ describe API::API do end context 'unauthenticated user' do - it 'should not return triggers list' do + it 'does not return triggers list' do get api("/projects/#{project.id}/triggers") expect(response).to have_http_status(401) @@ -116,14 +117,14 @@ describe API::API do describe 'GET /projects/:id/triggers/:token' do context 'authenticated user with valid permissions' do - it 'should return trigger details' do + it 'returns trigger details' do get api("/projects/#{project.id}/triggers/#{trigger.token}", user) expect(response).to have_http_status(200) expect(json_response).to be_a(Hash) end - it 'should respond with 404 Not Found if requesting non-existing trigger' do + it 'responds with 404 Not Found if requesting non-existing trigger' do get api("/projects/#{project.id}/triggers/abcdef012345", user) expect(response).to have_http_status(404) @@ -131,7 +132,7 @@ describe API::API do end context 'authenticated user with invalid permissions' do - it 'should not return triggers list' do + it 'does not return triggers list' do get api("/projects/#{project.id}/triggers/#{trigger.token}", user2) expect(response).to have_http_status(403) @@ -139,7 +140,7 @@ describe API::API do end context 'unauthenticated user' do - it 'should not return triggers list' do + it 'does not return triggers list' do get api("/projects/#{project.id}/triggers/#{trigger.token}") expect(response).to have_http_status(401) @@ -149,7 +150,7 @@ describe API::API do describe 'POST /projects/:id/triggers' do context 'authenticated user with valid permissions' do - it 'should create trigger' do + it 'creates trigger' do expect do post api("/projects/#{project.id}/triggers", user) end.to change{project.triggers.count}.by(1) @@ -160,7 +161,7 @@ describe API::API do end context 'authenticated user with invalid permissions' do - it 'should not create trigger' do + it 'does not create trigger' do post api("/projects/#{project.id}/triggers", user2) expect(response).to have_http_status(403) @@ -168,7 +169,7 @@ describe API::API do end context 'unauthenticated user' do - it 'should not create trigger' do + it 'does not create trigger' do post api("/projects/#{project.id}/triggers") expect(response).to have_http_status(401) @@ -178,14 +179,14 @@ describe API::API do describe 'DELETE /projects/:id/triggers/:token' do context 'authenticated user with valid permissions' do - it 'should delete trigger' do + it 'deletes trigger' do expect do delete api("/projects/#{project.id}/triggers/#{trigger.token}", user) end.to change{project.triggers.count}.by(-1) expect(response).to have_http_status(200) end - it 'should respond with 404 Not Found if requesting non-existing trigger' do + it 'responds with 404 Not Found if requesting non-existing trigger' do delete api("/projects/#{project.id}/triggers/abcdef012345", user) expect(response).to have_http_status(404) @@ -193,7 +194,7 @@ describe API::API do end context 'authenticated user with invalid permissions' do - it 'should not delete trigger' do + it 'does not delete trigger' do delete api("/projects/#{project.id}/triggers/#{trigger.token}", user2) expect(response).to have_http_status(403) @@ -201,7 +202,7 @@ describe API::API do end context 'unauthenticated user' do - it 'should not delete trigger' do + it 'does not delete trigger' do delete api("/projects/#{project.id}/triggers/#{trigger.token}") expect(response).to have_http_status(401) diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index e43e3e269bf..f83f4d2c9b1 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -13,7 +13,7 @@ describe API::API, api: true do describe "GET /users" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/users") expect(response).to have_http_status(401) end @@ -38,7 +38,7 @@ describe API::API, api: true do end end - it "should return an array of users" do + it "returns an array of users" do get api("/users", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -48,7 +48,7 @@ describe API::API, api: true do end['username']).to eq(username) end - it "should return one user" do + it "returns one user" do get api("/users?username=#{omniauth_user.username}", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -57,11 +57,12 @@ describe API::API, api: true do end context "when admin" do - it "should return an array of users" do + it "returns an array of users" do get api("/users", admin) expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.first.keys).to include 'email' + expect(json_response.first.keys).to include 'organization' expect(json_response.first.keys).to include 'identities' expect(json_response.first.keys).to include 'can_create_project' expect(json_response.first.keys).to include 'two_factor_enabled' @@ -72,25 +73,26 @@ describe API::API, api: true do end describe "GET /users/:id" do - it "should return a user by id" do + it "returns a user by id" do get api("/users/#{user.id}", user) expect(response).to have_http_status(200) expect(json_response['username']).to eq(user.username) end - it "should return a 401 if unauthenticated" do + it "returns a 401 if unauthenticated" do get api("/users/9998") expect(response).to have_http_status(401) end - it "should return a 404 error if user id not found" do + it "returns a 404 error if user id not found" do get api("/users/9999", user) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Not found') end - it "should return a 404 if invalid ID" do + it "returns a 404 for invalid ID" do get api("/users/1ASDF", user) + expect(response).to have_http_status(404) end end @@ -98,13 +100,13 @@ describe API::API, api: true do describe "POST /users" do before{ admin } - it "should create user" do + it "creates user" do expect do post api("/users", admin), attributes_for(:user, projects_limit: 3) end.to change { User.count }.by(1) end - it "should create user with correct attributes" do + it "creates user with correct attributes" do post api('/users', admin), attributes_for(:user, admin: true, can_create_group: true) expect(response).to have_http_status(201) user_id = json_response['id'] @@ -114,7 +116,7 @@ describe API::API, api: true do expect(new_user.can_create_group).to eq(true) end - it "should create non-admin user" do + 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) user_id = json_response['id'] @@ -124,7 +126,7 @@ describe API::API, api: true do expect(new_user.can_create_group).to eq(false) end - it "should create non-admin users by default" do + it "creates non-admin users by default" do post api('/users', admin), attributes_for(:user) expect(response).to have_http_status(201) user_id = json_response['id'] @@ -133,7 +135,7 @@ describe API::API, api: true do expect(new_user.admin).to eq(false) end - it "should return 201 Created on success" do + it "returns 201 Created on success" do post api("/users", admin), attributes_for(:user, projects_limit: 3) expect(response).to have_http_status(201) end @@ -148,7 +150,7 @@ describe API::API, api: true do expect(new_user.external).to be_falsy end - it 'should allow an external user to be created' do + it 'allows an external user to be created' do post api("/users", admin), attributes_for(:user, external: true) expect(response).to have_http_status(201) @@ -158,7 +160,7 @@ describe API::API, api: true do expect(new_user.external).to be_truthy end - it "should not create user with invalid email" do + it "does not create user with invalid email" do post api('/users', admin), email: 'invalid email', password: 'password', @@ -166,27 +168,27 @@ describe API::API, api: true do expect(response).to have_http_status(400) end - it 'should return 400 error if name not given' do + it 'returns 400 error if name not given' do post api('/users', admin), attributes_for(:user).except(:name) expect(response).to have_http_status(400) end - it 'should return 400 error if password not given' do + it 'returns 400 error if password not given' do post api('/users', admin), attributes_for(:user).except(:password) expect(response).to have_http_status(400) end - it 'should return 400 error if email not given' do + it 'returns 400 error if email not given' do post api('/users', admin), attributes_for(:user).except(:email) expect(response).to have_http_status(400) end - it 'should return 400 error if username not given' do + it 'returns 400 error if username not given' do post api('/users', admin), attributes_for(:user).except(:username) expect(response).to have_http_status(400) end - it 'should return 400 error if user does not validate' do + it 'returns 400 error if user does not validate' do post api('/users', admin), password: 'pass', email: 'test@example.com', @@ -205,7 +207,7 @@ describe API::API, api: true do to eq([Gitlab::Regex.namespace_regex_message]) end - it "shouldn't available for non admin users" do + it "is not available for non admin users" do post api("/users", user), attributes_for(:user) expect(response).to have_http_status(403) end @@ -219,7 +221,7 @@ describe API::API, api: true do name: 'foo' end - it 'should return 409 conflict error if user with same email exists' do + it 'returns 409 conflict error if user with same email exists' do expect do post api('/users', admin), name: 'foo', @@ -231,7 +233,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('Email has already been taken') end - it 'should return 409 conflict error if same username exists' do + it 'returns 409 conflict error if same username exists' do expect do post api('/users', admin), name: 'foo', @@ -246,7 +248,7 @@ describe API::API, api: true do end describe "GET /users/sign_up" do - it "should redirect to sign in page" do + it "redirects to sign in page" do get "/users/sign_up" expect(response).to have_http_status(302) expect(response).to redirect_to(new_user_session_path) @@ -258,55 +260,63 @@ describe API::API, api: true do before { admin } - it "should update user with new bio" do + it "updates user with new bio" do put api("/users/#{user.id}", admin), { bio: 'new test bio' } expect(response).to have_http_status(200) expect(json_response['bio']).to eq('new test bio') expect(user.reload.bio).to eq('new test bio') end - it 'should update user with his own email' do + it "updates user with organization" do + put api("/users/#{user.id}", admin), { organization: 'GitLab' } + + expect(response).to have_http_status(200) + expect(json_response['organization']).to eq('GitLab') + expect(user.reload.organization).to eq('GitLab') + end + + it 'updates user with his own email' do put api("/users/#{user.id}", admin), email: user.email expect(response).to have_http_status(200) expect(json_response['email']).to eq(user.email) expect(user.reload.email).to eq(user.email) end - it 'should update user with his own username' do + it 'updates user with his own username' do put api("/users/#{user.id}", admin), username: user.username expect(response).to have_http_status(200) expect(json_response['username']).to eq(user.username) expect(user.reload.username).to eq(user.username) end - it "should update user's existing identity" do + it "updates user's existing identity" do put api("/users/#{omniauth_user.id}", admin), provider: 'ldapmain', extern_uid: '654321' expect(response).to have_http_status(200) expect(omniauth_user.reload.identities.first.extern_uid).to eq('654321') end - it 'should update user with new identity' do + it 'updates user with new identity' do put api("/users/#{user.id}", admin), provider: 'github', extern_uid: '67890' expect(response).to have_http_status(200) expect(user.reload.identities.first.extern_uid).to eq('67890') expect(user.reload.identities.first.provider).to eq('github') end - it "should update admin status" do + it "updates admin status" do put api("/users/#{user.id}", admin), { admin: true } expect(response).to have_http_status(200) expect(json_response['is_admin']).to eq(true) expect(user.reload.admin).to eq(true) end - it "should update external status" do + it "updates external status" do put api("/users/#{user.id}", admin), { external: true } expect(response.status).to eq 200 expect(json_response['external']).to eq(true) expect(user.reload.external?).to be_truthy end - it "should not update admin status" do + it "does not update admin status" do put api("/users/#{admin_user.id}", admin), { can_create_group: false } expect(response).to have_http_status(200) expect(json_response['is_admin']).to eq(true) @@ -314,28 +324,30 @@ describe API::API, api: true do expect(admin_user.can_create_group).to eq(false) end - it "should not allow invalid update" do + it "does not allow invalid update" do put api("/users/#{user.id}", admin), { email: 'invalid email' } expect(response).to have_http_status(400) expect(user.reload.email).not_to eq('invalid email') end - it "shouldn't available for non admin users" do + it "is not available for non admin users" do put api("/users/#{user.id}", user), attributes_for(:user) expect(response).to have_http_status(403) end - it "should return 404 for non-existing user" do + it "returns 404 for non-existing user" do put api("/users/999999", admin), { bio: 'update should fail' } expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Not found') end - it "should raise error for invalid ID" do - expect{put api("/users/ASDF", admin) }.to raise_error(ActionController::RoutingError) + it "returns a 404 if invalid ID" do + put api("/users/ASDF", admin) + + expect(response).to have_http_status(404) end - it 'should return 400 error if user does not validate' do + it 'returns 400 error if user does not validate' do put api("/users/#{user.id}", admin), password: 'pass', email: 'test@example.com', @@ -361,13 +373,13 @@ describe API::API, api: true do @user = User.all.last end - it 'should return 409 conflict error if email address exists' do + it 'returns 409 conflict error if email address exists' do put api("/users/#{@user.id}", admin), email: 'test@example.com' expect(response).to have_http_status(409) expect(@user.reload.email).to eq(@user.email) end - it 'should return 409 conflict error if username taken' do + it 'returns 409 conflict error if username taken' do @user_id = User.all.last.id put api("/users/#{@user.id}", admin), username: 'test' expect(response).to have_http_status(409) @@ -379,28 +391,28 @@ describe API::API, api: true do describe "POST /users/:id/keys" do before { admin } - it "should not create invalid ssh key" do + it "does not create invalid ssh key" do post api("/users/#{user.id}/keys", admin), { title: "invalid key" } expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "key" not given') end - it 'should not create key without title' do + it 'does not create key without title' do post api("/users/#{user.id}/keys", admin), key: 'some key' expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "title" not given') end - it "should create ssh key" do + it "creates ssh key" do key_attrs = attributes_for :key expect do post api("/users/#{user.id}/keys", admin), key_attrs end.to change{ user.keys.count }.by(1) end - it "should return 405 for invalid ID" do - post api("/users/ASDF/keys", admin) - expect(response).to have_http_status(405) + it "returns 400 for invalid ID" do + post api("/users/999999/keys", admin) + expect(response).to have_http_status(400) end end @@ -408,20 +420,20 @@ describe API::API, api: true do before { admin } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api("/users/#{user.id}/keys") expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should return 404 for non-existing user' do + it 'returns 404 for non-existing user' do get api('/users/999999/keys', admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 User Not Found') end - it 'should return array of ssh keys' do + it 'returns array of ssh keys' do user.keys << key user.save get api("/users/#{user.id}/keys", admin) @@ -429,11 +441,6 @@ describe API::API, api: true do expect(json_response).to be_an Array expect(json_response.first['title']).to eq(key.title) end - - it "should return 405 for invalid ID" do - get api("/users/ASDF/keys", admin) - expect(response).to have_http_status(405) - end end end @@ -441,14 +448,14 @@ describe API::API, api: true do before { admin } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do delete api("/users/#{user.id}/keys/42") expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should delete existing key' do + it 'deletes existing key' do user.keys << key user.save expect do @@ -457,7 +464,7 @@ describe API::API, api: true do expect(response).to have_http_status(200) end - it 'should return 404 error if user not found' do + it 'returns 404 error if user not found' do user.keys << key user.save delete api("/users/999999/keys/#{key.id}", admin) @@ -465,7 +472,7 @@ describe API::API, api: true do expect(json_response['message']).to eq('404 User Not Found') end - it 'should return 404 error if key not foud' do + it 'returns 404 error if key not foud' do delete api("/users/#{user.id}/keys/42", admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Key Not Found') @@ -476,22 +483,23 @@ describe API::API, api: true do describe "POST /users/:id/emails" do before { admin } - it "should not create invalid email" do + it "does not create invalid email" do post api("/users/#{user.id}/emails", admin), {} expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "email" not given') end - it "should create email" do + it "creates email" do email_attrs = attributes_for :email expect do post api("/users/#{user.id}/emails", admin), email_attrs end.to change{ user.emails.count }.by(1) end - it "should raise error for invalid ID" do - post api("/users/ASDF/emails", admin) - expect(response).to have_http_status(405) + it "returns a 400 for invalid ID" do + post api("/users/999999/emails", admin) + + expect(response).to have_http_status(400) end end @@ -499,20 +507,20 @@ describe API::API, api: true do before { admin } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do get api("/users/#{user.id}/emails") expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should return 404 for non-existing user' do + it 'returns 404 for non-existing user' do get api('/users/999999/emails', admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 User Not Found') end - it 'should return array of emails' do + it 'returns array of emails' do user.emails << email user.save get api("/users/#{user.id}/emails", admin) @@ -521,9 +529,10 @@ describe API::API, api: true do expect(json_response.first['email']).to eq(email.email) end - it "should raise error for invalid ID" do + it "returns a 404 for invalid ID" do put api("/users/ASDF/emails", admin) - expect(response).to have_http_status(405) + + expect(response).to have_http_status(404) end end end @@ -532,14 +541,14 @@ describe API::API, api: true do before { admin } context 'when unauthenticated' do - it 'should return authentication error' do + it 'returns authentication error' do delete api("/users/#{user.id}/emails/42") expect(response).to have_http_status(401) end end context 'when authenticated' do - it 'should delete existing email' do + it 'deletes existing email' do user.emails << email user.save expect do @@ -548,7 +557,7 @@ describe API::API, api: true do expect(response).to have_http_status(200) end - it 'should return 404 error if user not found' do + it 'returns 404 error if user not found' do user.emails << email user.save delete api("/users/999999/emails/#{email.id}", admin) @@ -556,51 +565,57 @@ describe API::API, api: true do expect(json_response['message']).to eq('404 User Not Found') end - it 'should return 404 error if email not foud' do + it 'returns 404 error if email not foud' do delete api("/users/#{user.id}/emails/42", admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Email Not Found') end - it "should raise error for invalid ID" do - expect{delete api("/users/ASDF/emails/bar", admin) }.to raise_error(ActionController::RoutingError) + it "returns a 404 for invalid ID" do + delete api("/users/ASDF/emails/bar", admin) + + expect(response).to have_http_status(404) end end end describe "DELETE /users/:id" do + let!(:namespace) { user.namespace } before { admin } - it "should delete user" do + it "deletes user" do delete api("/users/#{user.id}", admin) expect(response).to have_http_status(200) expect { User.find(user.id) }.to raise_error ActiveRecord::RecordNotFound + expect { Namespace.find(namespace.id) }.to raise_error ActiveRecord::RecordNotFound expect(json_response['email']).to eq(user.email) end - it "should not delete for unauthenticated user" do + it "does not delete for unauthenticated user" do delete api("/users/#{user.id}") expect(response).to have_http_status(401) end - it "shouldn't available for non admin users" do + it "is not available for non admin users" do delete api("/users/#{user.id}", user) expect(response).to have_http_status(403) end - it "should return 404 for non-existing user" do + it "returns 404 for non-existing user" do delete api("/users/999999", admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 User Not Found') end - it "should raise error for invalid ID" do - expect{delete api("/users/ASDF", admin) }.to raise_error(ActionController::RoutingError) + it "returns a 404 for invalid ID" do + delete api("/users/ASDF", admin) + + expect(response).to have_http_status(404) end end describe "GET /user" do - it "should return current user" do + it "returns current user" do get api("/user", user) expect(response).to have_http_status(200) expect(json_response['email']).to eq(user.email) @@ -608,9 +623,10 @@ describe API::API, api: true do expect(json_response['can_create_project']).to eq(user.can_create_project?) expect(json_response['can_create_group']).to eq(user.can_create_group?) expect(json_response['projects_limit']).to eq(user.projects_limit) + expect(json_response['private_token']).to be_blank end - it "should return 401 error if user is unauthenticated" do + it "returns 401 error if user is unauthenticated" do get api("/user") expect(response).to have_http_status(401) end @@ -618,14 +634,14 @@ describe API::API, api: true do describe "GET /user/keys" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/user/keys") expect(response).to have_http_status(401) end end context "when authenticated" do - it "should return array of ssh keys" do + it "returns array of ssh keys" do user.keys << key user.save get api("/user/keys", user) @@ -637,7 +653,7 @@ describe API::API, api: true do end describe "GET /user/keys/:id" do - it "should return single key" do + it "returns single key" do user.keys << key user.save get api("/user/keys/#{key.id}", user) @@ -645,13 +661,14 @@ describe API::API, api: true do expect(json_response["title"]).to eq(key.title) end - it "should return 404 Not Found within invalid ID" do + it "returns 404 Not Found within invalid ID" do get api("/user/keys/42", user) + expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Not found') end - it "should return 404 error if admin accesses user's ssh key" do + it "returns 404 error if admin accesses user's ssh key" do user.keys << key user.save admin @@ -660,14 +677,15 @@ describe API::API, api: true do expect(json_response['message']).to eq('404 Not found') end - it "should return 404 for invalid ID" do + it "returns 404 for invalid ID" do get api("/users/keys/ASDF", admin) + expect(response).to have_http_status(404) end end describe "POST /user/keys" do - it "should create ssh key" do + it "creates ssh key" do key_attrs = attributes_for :key expect do post api("/user/keys", user), key_attrs @@ -675,31 +693,31 @@ describe API::API, api: true do expect(response).to have_http_status(201) end - it "should return a 401 error if unauthorized" do + it "returns a 401 error if unauthorized" do post api("/user/keys"), title: 'some title', key: 'some key' expect(response).to have_http_status(401) end - it "should not create ssh key without key" do + it "does not create ssh key without key" do post api("/user/keys", user), title: 'title' expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "key" not given') end - it 'should not create ssh key without title' do + it 'does not create ssh key without title' do post api('/user/keys', user), key: 'some key' expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "title" not given') end - it "should not create ssh key without title" do + it "does not create ssh key without title" do post api("/user/keys", user), key: "somekey" expect(response).to have_http_status(400) end end describe "DELETE /user/keys/:id" do - it "should delete existed key" do + it "deletes existed key" do user.keys << key user.save expect do @@ -708,33 +726,35 @@ describe API::API, api: true do expect(response).to have_http_status(200) end - it "should return success if key ID not found" do + it "returns success if key ID not found" do delete api("/user/keys/42", user) expect(response).to have_http_status(200) end - it "should return 401 error if unauthorized" do + it "returns 401 error if unauthorized" do user.keys << key user.save delete api("/user/keys/#{key.id}") expect(response).to have_http_status(401) end - it "should raise error for invalid ID" do - expect{delete api("/users/keys/ASDF", admin) }.to raise_error(ActionController::RoutingError) + it "returns a 404 for invalid ID" do + delete api("/users/keys/ASDF", admin) + + expect(response).to have_http_status(404) end end describe "GET /user/emails" do context "when unauthenticated" do - it "should return authentication error" do + it "returns authentication error" do get api("/user/emails") expect(response).to have_http_status(401) end end context "when authenticated" do - it "should return array of emails" do + it "returns array of emails" do user.emails << email user.save get api("/user/emails", user) @@ -746,7 +766,7 @@ describe API::API, api: true do end describe "GET /user/emails/:id" do - it "should return single email" do + it "returns single email" do user.emails << email user.save get api("/user/emails/#{email.id}", user) @@ -754,13 +774,13 @@ describe API::API, api: true do expect(json_response["email"]).to eq(email.email) end - it "should return 404 Not Found within invalid ID" do + it "returns 404 Not Found within invalid ID" do get api("/user/emails/42", user) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Not found') end - it "should return 404 error if admin accesses user's email" do + it "returns 404 error if admin accesses user's email" do user.emails << email user.save admin @@ -769,14 +789,15 @@ describe API::API, api: true do expect(json_response['message']).to eq('404 Not found') end - it "should return 404 for invalid ID" do + it "returns 404 for invalid ID" do get api("/users/emails/ASDF", admin) + expect(response).to have_http_status(404) end end describe "POST /user/emails" do - it "should create email" do + it "creates email" do email_attrs = attributes_for :email expect do post api("/user/emails", user), email_attrs @@ -784,12 +805,12 @@ describe API::API, api: true do expect(response).to have_http_status(201) end - it "should return a 401 error if unauthorized" do + it "returns a 401 error if unauthorized" do post api("/user/emails"), email: 'some email' expect(response).to have_http_status(401) end - it "should not create email with invalid email" do + it "does not create email with invalid email" do post api("/user/emails", user), {} expect(response).to have_http_status(400) expect(json_response['message']).to eq('400 (Bad request) "email" not given') @@ -797,7 +818,7 @@ describe API::API, api: true do end describe "DELETE /user/emails/:id" do - it "should delete existed email" do + it "deletes existed email" do user.emails << email user.save expect do @@ -806,44 +827,46 @@ describe API::API, api: true do expect(response).to have_http_status(200) end - it "should return success if email ID not found" do + it "returns success if email ID not found" do delete api("/user/emails/42", user) expect(response).to have_http_status(200) end - it "should return 401 error if unauthorized" do + it "returns 401 error if unauthorized" do user.emails << email user.save delete api("/user/emails/#{email.id}") expect(response).to have_http_status(401) end - it "should raise error for invalid ID" do - expect{delete api("/users/emails/ASDF", admin) }.to raise_error(ActionController::RoutingError) + it "returns a 404 for invalid ID" do + delete api("/users/emails/ASDF", admin) + + expect(response).to have_http_status(404) end end describe 'PUT /user/:id/block' do before { admin } - it 'should block existing user' do + it 'blocks existing user' do put api("/users/#{user.id}/block", admin) expect(response).to have_http_status(200) expect(user.reload.state).to eq('blocked') end - it 'should not re-block ldap blocked users' do + it 'does not re-block ldap blocked users' do put api("/users/#{ldap_blocked_user.id}/block", admin) expect(response).to have_http_status(403) expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') end - it 'should not be available for non admin users' do + it 'does not be available for non admin users' do put api("/users/#{user.id}/block", user) expect(response).to have_http_status(403) expect(user.reload.state).to eq('active') end - it 'should return a 404 error if user id not found' do + it 'returns a 404 error if user id not found' do put api('/users/9999/block', admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 User Not Found') @@ -854,38 +877,94 @@ describe API::API, api: true do let(:blocked_user) { create(:user, state: 'blocked') } before { admin } - it 'should unblock existing user' do + it 'unblocks existing user' do put api("/users/#{user.id}/unblock", admin) expect(response).to have_http_status(200) expect(user.reload.state).to eq('active') end - it 'should unblock a blocked user' do + it 'unblocks a blocked user' do put api("/users/#{blocked_user.id}/unblock", admin) expect(response).to have_http_status(200) expect(blocked_user.reload.state).to eq('active') end - it 'should not unblock ldap blocked users' do + it 'does not unblock ldap blocked users' do put api("/users/#{ldap_blocked_user.id}/unblock", admin) expect(response).to have_http_status(403) expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') end - it 'should not be available for non admin users' do + it 'does not be available for non admin users' do put api("/users/#{user.id}/unblock", user) expect(response).to have_http_status(403) expect(user.reload.state).to eq('active') end - it 'should return a 404 error if user id not found' do + it 'returns a 404 error if user id not found' do put api('/users/9999/block', admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 User Not Found') end - it "should raise error for invalid ID" do - expect{put api("/users/ASDF/block", admin) }.to raise_error(ActionController::RoutingError) + it "returns a 404 for invalid ID" do + put api("/users/ASDF/block", admin) + + expect(response).to have_http_status(404) + end + end + + describe 'GET /user/:id/events' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) } + + before do + project.add_user(user, :developer) + EventCreateService.new.leave_note(note, user) + end + + context "as a user than cannot see the event's project" do + it 'returns no events' do + other_user = create(:user) + + get api("/users/#{user.id}/events", other_user) + + expect(response).to have_http_status(200) + expect(json_response).to be_empty + end + end + + context "as a user than can see the event's project" do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/users/#{user.id}/events", user) } + end + + context 'joined event' do + it 'returns the "joined" event' do + get api("/users/#{user.id}/events", user) + + comment_event = json_response.find { |e| e['action_name'] == 'commented on' } + + expect(comment_event['project_id'].to_i).to eq(project.id) + expect(comment_event['author_username']).to eq(user.username) + expect(comment_event['note']['id']).to eq(note.id) + expect(comment_event['note']['body']).to eq('What an awesome day!') + + joined_event = json_response.find { |e| e['action_name'] == 'joined' } + + expect(joined_event['project_id'].to_i).to eq(project.id) + expect(joined_event['author_username']).to eq(user.username) + expect(joined_event['author']['name']).to eq(user.name) + end + end + end + + it 'returns a 404 error if not found' do + get api('/users/42/events', user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') end end end diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb index ddba18245f8..05fbdb909dc 100644 --- a/spec/requests/api/variables_spec.rb +++ b/spec/requests/api/variables_spec.rb @@ -12,7 +12,7 @@ describe API::API, api: true do describe 'GET /projects/:id/variables' do context 'authorized user with proper permissions' do - it 'should return project variables' do + it 'returns project variables' do get api("/projects/#{project.id}/variables", user) expect(response).to have_http_status(200) @@ -21,7 +21,7 @@ describe API::API, api: true do end context 'authorized user with invalid permissions' do - it 'should not return project variables' do + it 'does not return project variables' do get api("/projects/#{project.id}/variables", user2) expect(response).to have_http_status(403) @@ -29,7 +29,7 @@ describe API::API, api: true do end context 'unauthorized user' do - it 'should not return project variables' do + it 'does not return project variables' do get api("/projects/#{project.id}/variables") expect(response).to have_http_status(401) @@ -39,14 +39,14 @@ describe API::API, api: true do describe 'GET /projects/:id/variables/:key' do context 'authorized user with proper permissions' do - it 'should return project variable details' do + it 'returns project variable details' do get api("/projects/#{project.id}/variables/#{variable.key}", user) expect(response).to have_http_status(200) expect(json_response['value']).to eq(variable.value) end - it 'should respond with 404 Not Found if requesting non-existing variable' do + it 'responds with 404 Not Found if requesting non-existing variable' do get api("/projects/#{project.id}/variables/non_existing_variable", user) expect(response).to have_http_status(404) @@ -54,7 +54,7 @@ describe API::API, api: true do end context 'authorized user with invalid permissions' do - it 'should not return project variable details' do + it 'does not return project variable details' do get api("/projects/#{project.id}/variables/#{variable.key}", user2) expect(response).to have_http_status(403) @@ -62,7 +62,7 @@ describe API::API, api: true do end context 'unauthorized user' do - it 'should not return project variable details' do + it 'does not return project variable details' do get api("/projects/#{project.id}/variables/#{variable.key}") expect(response).to have_http_status(401) @@ -72,7 +72,7 @@ describe API::API, api: true do describe 'POST /projects/:id/variables' do context 'authorized user with proper permissions' do - it 'should create variable' do + it 'creates variable' do expect do post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2' end.to change{project.variables.count}.by(1) @@ -82,7 +82,7 @@ describe API::API, api: true do expect(json_response['value']).to eq('VALUE_2') end - it 'should not allow to duplicate variable key' do + it 'does not allow to duplicate variable key' do expect do post api("/projects/#{project.id}/variables", user), key: variable.key, value: 'VALUE_2' end.to change{project.variables.count}.by(0) @@ -92,7 +92,7 @@ describe API::API, api: true do end context 'authorized user with invalid permissions' do - it 'should not create variable' do + it 'does not create variable' do post api("/projects/#{project.id}/variables", user2) expect(response).to have_http_status(403) @@ -100,7 +100,7 @@ describe API::API, api: true do end context 'unauthorized user' do - it 'should not create variable' do + it 'does not create variable' do post api("/projects/#{project.id}/variables") expect(response).to have_http_status(401) @@ -110,7 +110,7 @@ describe API::API, api: true do describe 'PUT /projects/:id/variables/:key' do context 'authorized user with proper permissions' do - it 'should update variable data' do + it 'updates variable data' do initial_variable = project.variables.first value_before = initial_variable.value @@ -123,7 +123,7 @@ describe API::API, api: true do expect(updated_variable.value).to eq('VALUE_1_UP') end - it 'should responde with 404 Not Found if requesting non-existing variable' do + it 'responds with 404 Not Found if requesting non-existing variable' do put api("/projects/#{project.id}/variables/non_existing_variable", user) expect(response).to have_http_status(404) @@ -131,7 +131,7 @@ describe API::API, api: true do end context 'authorized user with invalid permissions' do - it 'should not update variable' do + it 'does not update variable' do put api("/projects/#{project.id}/variables/#{variable.key}", user2) expect(response).to have_http_status(403) @@ -139,7 +139,7 @@ describe API::API, api: true do end context 'unauthorized user' do - it 'should not update variable' do + it 'does not update variable' do put api("/projects/#{project.id}/variables/#{variable.key}") expect(response).to have_http_status(401) @@ -149,14 +149,14 @@ describe API::API, api: true do describe 'DELETE /projects/:id/variables/:key' do context 'authorized user with proper permissions' do - it 'should delete variable' do + it 'deletes variable' do expect do delete api("/projects/#{project.id}/variables/#{variable.key}", user) end.to change{project.variables.count}.by(-1) expect(response).to have_http_status(200) end - it 'should responde with 404 Not Found if requesting non-existing variable' do + it 'responds with 404 Not Found if requesting non-existing variable' do delete api("/projects/#{project.id}/variables/non_existing_variable", user) expect(response).to have_http_status(404) @@ -164,7 +164,7 @@ describe API::API, api: true do end context 'authorized user with invalid permissions' do - it 'should not delete variable' do + it 'does not delete variable' do delete api("/projects/#{project.id}/variables/#{variable.key}", user2) expect(response).to have_http_status(403) @@ -172,7 +172,7 @@ describe API::API, api: true do end context 'unauthorized user' do - it 'should not delete variable' do + it 'does not delete variable' do delete api("/projects/#{project.id}/variables/#{variable.key}") expect(response).to have_http_status(401) diff --git a/spec/requests/api/version_spec.rb b/spec/requests/api/version_spec.rb new file mode 100644 index 00000000000..54b69a0cae7 --- /dev/null +++ b/spec/requests/api/version_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + describe 'GET /version' do + context 'when unauthenticated' do + it 'returns authentication error' do + get api('/version') + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + let(:user) { create(:user) } + + it 'returns the version information' do + get api('/version', user) + + expect(response).to have_http_status(200) + expect(json_response['version']).to eq(Gitlab::VERSION) + expect(json_response['revision']).to eq(Gitlab::REVISION) + end + end + end +end diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index cf1e8d9b514..7b7d62feb2c 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -6,112 +6,121 @@ describe Ci::API::API do let(:runner) { FactoryGirl.create(:ci_runner, tag_list: ["mysql", "ruby"]) } let(:project) { FactoryGirl.create(:empty_project) } - before do - stub_ci_pipeline_to_return_yaml_file - end - describe "Builds API for runners" do - let(:shared_runner) { FactoryGirl.create(:ci_runner, token: "SharedRunner") } - let(:shared_project) { FactoryGirl.create(:empty_project, name: "SharedProject") } + let(:pipeline) { create(:ci_pipeline_without_jobs, project: project, ref: 'master') } before do - FactoryGirl.create :ci_runner_project, project: project, runner: runner + project.runners << runner end describe "POST /builds/register" do - it "should start a build" do - pipeline = FactoryGirl.create(:ci_pipeline, project: project, ref: 'master') - pipeline.create_builds(nil) - build = pipeline.builds.first + let!(:build) { create(:ci_build, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let(:user_agent) { 'gitlab-ci-multi-runner 1.5.2 (1-5-stable; go1.6.3; linux/amd64)' } + + shared_examples 'no builds available' do + context 'when runner sends version in User-Agent' do + context 'for stable version' do + it { expect(response).to have_http_status(204) } + end - post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } + context 'for beta version' do + let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0~beta.167.g2b2bacc (1-5-stable; go1.6.3; linux/amd64)' } + it { expect(response).to have_http_status(204) } + end + end - expect(response).to have_http_status(201) - expect(json_response['sha']).to eq(build.sha) - expect(runner.reload.platform).to eq("darwin") + context "when runner doesn't send version in User-Agent" do + let(:user_agent) { 'Go-http-client/1.1' } + it { expect(response).to have_http_status(404) } + end end - it "should return 404 error if no pending build found" do - post ci_api("/builds/register"), token: runner.token + context 'when there is a pending build' do + it 'starts a build' do + register_builds info: { platform: :darwin } + + expect(response).to have_http_status(201) + expect(json_response['sha']).to eq(build.sha) + expect(runner.reload.platform).to eq("darwin") + expect(json_response["options"]).to eq({ "image" => "ruby:2.1", "services" => ["postgres"] }) + expect(json_response["variables"]).to include( + { "key" => "CI_BUILD_NAME", "value" => "spinach", "public" => true }, + { "key" => "CI_BUILD_STAGE", "value" => "test", "public" => true }, + { "key" => "DB_NAME", "value" => "postgres", "public" => true } + ) + end - expect(response).to have_http_status(404) + it 'updates runner info' do + expect { register_builds }.to change { runner.reload.contacted_at } + end end - it "should return 404 error if no builds for specific runner" do - pipeline = FactoryGirl.create(:ci_pipeline, project: shared_project) - FactoryGirl.create(:ci_build, pipeline: pipeline, status: 'pending') - - post ci_api("/builds/register"), token: runner.token + context 'when builds are finished' do + before do + build.success + register_builds + end - expect(response).to have_http_status(404) + it_behaves_like 'no builds available' end - it "should return 404 error if no builds for shared runner" do - pipeline = FactoryGirl.create(:ci_pipeline, project: project) - FactoryGirl.create(:ci_build, pipeline: pipeline, status: 'pending') - - post ci_api("/builds/register"), token: shared_runner.token + context 'for other project with builds' do + before do + build.success + create(:ci_build, :pending) + register_builds + end - expect(response).to have_http_status(404) + it_behaves_like 'no builds available' end - it "returns options" do - pipeline = FactoryGirl.create(:ci_pipeline, project: project, ref: 'master') - pipeline.create_builds(nil) + context 'for shared runner' do + let(:shared_runner) { create(:ci_runner, token: "SharedRunner") } - post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } + before do + register_builds shared_runner.token + end - expect(response).to have_http_status(201) - expect(json_response["options"]).to eq({ "image" => "ruby:2.1", "services" => ["postgres"] }) + it_behaves_like 'no builds available' end - it "returns variables" do - pipeline = FactoryGirl.create(:ci_pipeline, project: project, ref: 'master') - pipeline.create_builds(nil) - project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") - - post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } + context 'for triggered build' do + before do + trigger = create(:ci_trigger, project: project) + create(:ci_trigger_request_with_variables, pipeline: pipeline, builds: [build], trigger: trigger) + project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") + end - expect(response).to have_http_status(201) - expect(json_response["variables"]).to include( - { "key" => "CI_BUILD_NAME", "value" => "spinach", "public" => true }, - { "key" => "CI_BUILD_STAGE", "value" => "test", "public" => true }, - { "key" => "DB_NAME", "value" => "postgres", "public" => true }, - { "key" => "SECRET_KEY", "value" => "secret_value", "public" => false } - ) + it "returns variables for triggers" do + register_builds info: { platform: :darwin } + + expect(response).to have_http_status(201) + expect(json_response["variables"]).to include( + { "key" => "CI_BUILD_NAME", "value" => "spinach", "public" => true }, + { "key" => "CI_BUILD_STAGE", "value" => "test", "public" => true }, + { "key" => "CI_BUILD_TRIGGERED", "value" => "true", "public" => true }, + { "key" => "DB_NAME", "value" => "postgres", "public" => true }, + { "key" => "SECRET_KEY", "value" => "secret_value", "public" => false }, + { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false }, + ) + end end - it "returns variables for triggers" do - trigger = FactoryGirl.create(:ci_trigger, project: project) - pipeline = FactoryGirl.create(:ci_pipeline, project: project, ref: 'master') - - trigger_request = FactoryGirl.create(:ci_trigger_request_with_variables, pipeline: pipeline, trigger: trigger) - pipeline.create_builds(nil, trigger_request) - project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") - - post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } - - expect(response).to have_http_status(201) - expect(json_response["variables"]).to include( - { "key" => "CI_BUILD_NAME", "value" => "spinach", "public" => true }, - { "key" => "CI_BUILD_STAGE", "value" => "test", "public" => true }, - { "key" => "CI_BUILD_TRIGGERED", "value" => "true", "public" => true }, - { "key" => "DB_NAME", "value" => "postgres", "public" => true }, - { "key" => "SECRET_KEY", "value" => "secret_value", "public" => false }, - { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false } - ) - end + context 'with multiple builds' do + before do + build.success + end - it "returns dependent builds" do - pipeline = FactoryGirl.create(:ci_pipeline, project: project, ref: 'master') - pipeline.create_builds(nil, nil) - pipeline.builds.where(stage: 'test').each(&:success) + let!(:test_build) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) } - post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } + it "returns dependent builds" do + register_builds info: { platform: :darwin } - expect(response).to have_http_status(201) - expect(json_response["depends_on_builds"].count).to eq(2) - expect(json_response["depends_on_builds"][0]["name"]).to eq("rspec") + expect(response).to have_http_status(201) + expect(json_response["id"]).to eq(test_build.id) + expect(json_response["depends_on_builds"].count).to eq(1) + expect(json_response["depends_on_builds"][0]).to include('id' => build.id, 'name' => 'spinach') + end end %w(name version revision platform architecture).each do |param| @@ -121,8 +130,9 @@ describe Ci::API::API do subject { runner.read_attribute(param.to_sym) } it do - post ci_api("/builds/register"), token: runner.token, info: { param => value } - expect(response).to have_http_status(404) + register_builds info: { param => value } + + expect(response).to have_http_status(201) runner.reload is_expected.to eq(value) end @@ -131,8 +141,7 @@ describe Ci::API::API do context 'when build has no tags' do before do - pipeline = create(:ci_pipeline, project: project) - create(:ci_build, pipeline: pipeline, tags: []) + build.update(tags: []) end context 'when runner is allowed to pick untagged builds' do @@ -146,50 +155,62 @@ describe Ci::API::API do end context 'when runner is not allowed to pick untagged builds' do - before { runner.update_column(:run_untagged, false) } - - it 'does not pick build' do + before do + runner.update_column(:run_untagged, false) register_builds - - expect(response).to have_http_status 404 end + + it_behaves_like 'no builds available' end + end + + context 'when runner is paused' do + let(:runner) { create(:ci_runner, :inactive, token: 'InactiveRunner') } - def register_builds - post ci_api("/builds/register"), token: runner.token, - info: { platform: :darwin } + it 'responds with 404' do + register_builds + + expect(response).to have_http_status 404 + end + + it 'does not update runner info' do + expect { register_builds } + .not_to change { runner.reload.contacted_at } end end + + def register_builds(token = runner.token, **params) + post ci_api("/builds/register"), params.merge(token: token), { 'User-Agent' => user_agent } + end end describe "PUT /builds/:id" do - let(:pipeline) {create(:ci_pipeline, project: project)} - let(:build) { create(:ci_build, :trace, pipeline: pipeline, runner_id: runner.id) } + let(:build) { create(:ci_build, :pending, :trace, pipeline: pipeline, runner_id: runner.id) } before do build.run! put ci_api("/builds/#{build.id}"), token: runner.token end - it "should update a running build" do + it "updates a running build" do expect(response).to have_http_status(200) end - it 'should not override trace information when no trace is given' do + it 'does not override trace information when no trace is given' do expect(build.reload.trace).to eq 'BUILD TRACE' end context 'build has been erased' do let(:build) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) } - it 'should respond with forbidden' do + it 'responds with forbidden' do expect(response.status).to eq 403 end end end describe 'PATCH /builds/:id/trace.txt' do - let(:build) { create(:ci_build, :trace, runner_id: runner.id) } + let(:build) { create(:ci_build, :pending, :trace, runner_id: runner.id) } let(:headers) { { Ci::API::Helpers::BUILD_TOKEN_HEADER => build.token, 'Content-Type' => 'text/plain' } } let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) } @@ -237,14 +258,15 @@ describe Ci::API::API do context "Artifacts" do let(:file_upload) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') } let(:file_upload2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/gif') } - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline, runner_id: runner.id) } + let(:build) { create(:ci_build, :pending, pipeline: pipeline, runner_id: runner.id) } let(:authorize_url) { ci_api("/builds/#{build.id}/artifacts/authorize") } let(:post_url) { ci_api("/builds/#{build.id}/artifacts") } let(:delete_url) { ci_api("/builds/#{build.id}/artifacts") } let(:get_url) { ci_api("/builds/#{build.id}/artifacts") } - let(:headers) { { "GitLab-Workhorse" => "1.0" } } - let(:headers_with_token) { headers.merge(Ci::API::Helpers::BUILD_TOKEN_HEADER => build.token) } + let(:jwt_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:headers) { { "GitLab-Workhorse" => "1.0", Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => jwt_token } } + let(:token) { build.token } + let(:headers_with_token) { headers.merge(Ci::API::Helpers::BUILD_TOKEN_HEADER => token) } before { build.run! } @@ -252,27 +274,51 @@ describe Ci::API::API do context "should authorize posting artifact to running build" do it "using token as parameter" do post authorize_url, { token: build.token }, headers + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) expect(json_response["TempPath"]).not_to be_nil end it "using token as header" do post authorize_url, {}, headers_with_token + + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response["TempPath"]).not_to be_nil + end + + it "using runners token" do + post authorize_url, { token: build.project.runners_token }, headers + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) expect(json_response["TempPath"]).not_to be_nil end + + it "reject requests that did not go through gitlab-workhorse" do + headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + post authorize_url, { token: build.token }, headers + + expect(response).to have_http_status(500) + end end context "should fail to post too large artifact" do it "using token as parameter" do stub_application_setting(max_artifacts_size: 0) + post authorize_url, { token: build.token, filesize: 100 }, headers + expect(response).to have_http_status(413) end it "using token as header" do stub_application_setting(max_artifacts_size: 0) + post authorize_url, { filesize: 100 }, headers_with_token + expect(response).to have_http_status(413) end end @@ -280,7 +326,7 @@ describe Ci::API::API do context 'authorization token is invalid' do before { post authorize_url, { token: 'invalid', filesize: 100 } } - it 'should respond with forbidden' do + it 'responds with forbidden' do expect(response).to have_http_status(403) end end @@ -300,7 +346,7 @@ describe Ci::API::API do upload_artifacts(file_upload, headers_with_token) end - it 'should respond with forbidden' do + it 'responds with forbidden' do expect(response.status).to eq 403 end end @@ -340,9 +386,19 @@ describe Ci::API::API do it_behaves_like 'successful artifacts upload' end + + context 'when using runners token' do + let(:token) { build.project.runners_token } + + before do + upload_artifacts(file_upload, headers_with_token) + end + + it_behaves_like 'successful artifacts upload' + end end - context 'should post artifacts file and metadata file' do + context 'posts artifacts file and metadata file' do let!(:artifacts) { file_upload } let!(:metadata) { file_upload2 } @@ -354,7 +410,7 @@ describe Ci::API::API do post(post_url, post_data, headers_with_token) end - context 'post data accelerated by workhorse is correct' do + context 'posts data accelerated by workhorse is correct' do let(:post_data) do { 'file.path' => artifacts.path, 'file.name' => artifacts.original_filename, @@ -422,7 +478,7 @@ describe Ci::API::API do end context "artifacts file is too large" do - it "should fail to post too large artifact" do + it "fails to post too large artifact" do stub_application_setting(max_artifacts_size: 0) upload_artifacts(file_upload, headers_with_token) expect(response).to have_http_status(413) @@ -430,14 +486,14 @@ describe Ci::API::API do end context "artifacts post request does not contain file" do - it "should fail to post artifacts without file" do + it "fails to post artifacts without file" do post post_url, {}, headers_with_token expect(response).to have_http_status(400) end end context 'GitLab Workhorse is not configured' do - it "should fail to post artifacts without GitLab-Workhorse" do + it "fails to post artifacts without GitLab-Workhorse" do post post_url, { token: build.token }, {} expect(response).to have_http_status(403) end @@ -456,7 +512,7 @@ describe Ci::API::API do FileUtils.remove_entry @tmpdir end - it "should fail to post artifacts for outside of tmp path" do + it "fails to post artifacts for outside of tmp path" do upload_artifacts(file_upload, headers_with_token) expect(response).to have_http_status(400) end @@ -479,19 +535,40 @@ describe Ci::API::API do before do delete delete_url, token: build.token - build.reload end - it 'should remove build artifacts' do - expect(response).to have_http_status(200) - expect(build.artifacts_file.exists?).to be_falsy - expect(build.artifacts_metadata.exists?).to be_falsy - expect(build.artifacts_size).to be_nil + shared_examples 'having removable artifacts' do + it 'removes build artifacts' do + build.reload + + expect(response).to have_http_status(200) + expect(build.artifacts_file.exists?).to be_falsy + expect(build.artifacts_metadata.exists?).to be_falsy + expect(build.artifacts_size).to be_nil + end + end + + context 'when using build token' do + before do + delete delete_url, token: build.token + end + + it_behaves_like 'having removable artifacts' + end + + context 'when using runnners token' do + before do + delete delete_url, token: build.project.runners_token + end + + it_behaves_like 'having removable artifacts' end end describe 'GET /builds/:id/artifacts' do - before { get get_url, token: build.token } + before do + get get_url, token: token + end context 'build has artifacts' do let(:build) { create(:ci_build, :artifacts) } @@ -500,14 +577,30 @@ describe Ci::API::API do 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } end - it 'should download artifact' do - expect(response).to have_http_status(200) - expect(response.headers).to include download_headers + shared_examples 'having downloadable artifacts' do + it 'download artifacts' do + expect(response).to have_http_status(200) + expect(response.headers).to include download_headers + end + end + + context 'when using build token' do + let(:token) { build.token } + + it_behaves_like 'having downloadable artifacts' + end + + context 'when using runnners token' do + let(:token) { build.project.runners_token } + + it_behaves_like 'having downloadable artifacts' end end context 'build does not has artifacts' do - it 'should respond with not found' do + let(:token) { build.token } + + it 'responds with not found' do expect(response).to have_http_status(404) end end diff --git a/spec/requests/ci/api/triggers_spec.rb b/spec/requests/ci/api/triggers_spec.rb index f12678e5a8e..0a0f979f57d 100644 --- a/spec/requests/ci/api/triggers_spec.rb +++ b/spec/requests/ci/api/triggers_spec.rb @@ -19,17 +19,17 @@ describe Ci::API::API do end context 'Handles errors' do - it 'should return bad request if token is missing' do + it 'returns bad request if token is missing' do post ci_api("/projects/#{project.ci_id}/refs/master/trigger") expect(response).to have_http_status(400) end - it 'should return not found if project is not found' do + it 'returns not found if project is not found' do post ci_api('/projects/0/refs/master/trigger'), options expect(response).to have_http_status(404) end - it 'should return unauthorized if token is for different project' do + it 'returns unauthorized if token is for different project' do post ci_api("/projects/#{project2.ci_id}/refs/master/trigger"), options expect(response).to have_http_status(401) end @@ -38,14 +38,15 @@ describe Ci::API::API do context 'Have a commit' do let(:pipeline) { project.pipelines.last } - it 'should create builds' do + it 'creates builds' do post ci_api("/projects/#{project.ci_id}/refs/master/trigger"), options expect(response).to have_http_status(201) pipeline.builds.reload - expect(pipeline.builds.size).to eq(2) + expect(pipeline.builds.pending.size).to eq(2) + expect(pipeline.builds.size).to eq(5) end - it 'should return bad request with no builds created if there\'s no commit for that ref' do + it 'returns bad request with no builds created if there\'s no commit for that ref' do post ci_api("/projects/#{project.ci_id}/refs/other-branch/trigger"), options expect(response).to have_http_status(400) expect(json_response['message']).to eq('No builds created') @@ -56,19 +57,19 @@ describe Ci::API::API do { 'TRIGGER_KEY' => 'TRIGGER_VALUE' } end - it 'should validate variables to be a hash' do + it 'validates variables to be a hash' do post ci_api("/projects/#{project.ci_id}/refs/master/trigger"), options.merge(variables: 'value') expect(response).to have_http_status(400) expect(json_response['message']).to eq('variables needs to be a hash') end - it 'should validate variables needs to be a map of key-valued strings' do + it 'validates variables needs to be a map of key-valued strings' do post ci_api("/projects/#{project.ci_id}/refs/master/trigger"), options.merge(variables: { key: %w(1 2) }) expect(response).to have_http_status(400) expect(json_response['message']).to eq('variables needs to be a map of key-valued strings') end - it 'create trigger request with variables' do + it 'creates trigger request with variables' do post ci_api("/projects/#{project.ci_id}/refs/master/trigger"), options.merge(variables: variables) expect(response).to have_http_status(201) pipeline.builds.reload diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 82ab582beac..5a1ed7d4a25 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -1,8 +1,8 @@ require "spec_helper" describe 'Git HTTP requests', lib: true do - let(:user) { create(:user) } - let(:project) { create(:project, path: 'project.git-project') } + include GitHttpHelpers + include WorkhorseHelpers it "gives WWW-Authenticate hints" do clone_get('doesnt/exist.git') @@ -10,389 +10,507 @@ describe 'Git HTTP requests', lib: true do expect(response.header['WWW-Authenticate']).to start_with('Basic ') end - context "when the project doesn't exist" do - context "when no authentication is provided" do - it "responds with status 401 (no project existence information leak)" do - download('doesnt/exist.git') do |response| - expect(response).to have_http_status(401) - end - end - end + describe "User with no identities" do + let(:user) { create(:user) } + let(:project) { create(:project, path: 'project.git-project') } - context "when username and password are provided" do - context "when authentication fails" do - it "responds with status 401" do - download('doesnt/exist.git', user: user.username, password: "nope") do |response| + context "when the project doesn't exist" do + context "when no authentication is provided" do + it "responds with status 401 (no project existence information leak)" do + download('doesnt/exist.git') do |response| expect(response).to have_http_status(401) end end end - context "when authentication succeeds" do - it "responds with status 404" do - download('/doesnt/exist.git', user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) + context "when username and password are provided" do + context "when authentication fails" do + it "responds with status 401" do + download('doesnt/exist.git', user: user.username, password: "nope") do |response| + expect(response).to have_http_status(401) + end end end - end - end - end - - context "when the Wiki for a project exists" do - it "responds with the right project" do - wiki = ProjectWiki.new(project) - project.update_attribute(:visibility_level, Project::PUBLIC) - download("/#{wiki.repository.path_with_namespace}.git") do |response| - json_body = ActiveSupport::JSON.decode(response.body) - - expect(response).to have_http_status(200) - expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace) + context "when authentication succeeds" do + it "responds with status 404" do + download('/doesnt/exist.git', user: user.username, password: user.password) do |response| + expect(response).to have_http_status(404) + end + end + end end end - end - - context "when the project exists" do - let(:path) { "#{project.path_with_namespace}.git" } - context "when the project is public" do - before do + context "when the Wiki for a project exists" do + it "responds with the right project" do + wiki = ProjectWiki.new(project) project.update_attribute(:visibility_level, Project::PUBLIC) - end - it "downloads get status 200" do - download(path, {}) do |response| + download("/#{wiki.repository.path_with_namespace}.git") do |response| + json_body = ActiveSupport::JSON.decode(response.body) + expect(response).to have_http_status(200) + expect(json_body['RepoPath']).to include(wiki.repository.path_with_namespace) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) end end + end - it "uploads get status 401" do - upload(path, {}) do |response| - expect(response).to have_http_status(401) - end - end + context "when the project exists" do + let(:path) { "#{project.path_with_namespace}.git" } - context "with correct credentials" do - let(:env) { { user: user.username, password: user.password } } + context "when the project is public" do + before do + project.update_attribute(:visibility_level, Project::PUBLIC) + end - it "uploads get status 200 (because Git hooks do the real check)" do - upload(path, env) do |response| + it "downloads get status 200" do + download(path, {}) do |response| expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) end end - context 'but git-receive-pack is disabled' do - it "responds with status 404" do - allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) + it "uploads get status 401" do + upload(path, {}) do |response| + expect(response).to have_http_status(401) + end + end + context "with correct credentials" do + let(:env) { { user: user.username, password: user.password } } + + it "uploads get status 403" do upload(path, env) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(403) end end - end - end - context 'but git-upload-pack is disabled' do - it "responds with status 404" do - allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) + context 'but git-receive-pack is disabled' do + it "responds with status 404" do + allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) - download(path, {}) do |response| - expect(response).to have_http_status(404) + upload(path, env) do |response| + expect(response).to have_http_status(403) + end + end end end - end - end - context "when the project is private" do - before do - project.update_attribute(:visibility_level, Project::PRIVATE) - end + context 'but git-upload-pack is disabled' do + it "responds with status 404" do + allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false) - context "when no authentication is provided" do - it "responds with status 401 to downloads" do - download(path, {}) do |response| - expect(response).to have_http_status(401) + download(path, {}) do |response| + expect(response).to have_http_status(404) + end end end - it "responds with status 401 to uploads" do - upload(path, {}) do |response| - expect(response).to have_http_status(401) + context 'when the request is not from gitlab-workhorse' do + it 'raises an exception' do + expect do + get("/#{project.path_with_namespace}.git/info/refs?service=git-upload-pack") + end.to raise_error(JWT::DecodeError) end end end - context "when username and password are provided" do - let(:env) { { user: user.username, password: 'nope' } } + context "when the project is private" do + before do + project.update_attribute(:visibility_level, Project::PRIVATE) + end - context "when authentication fails" do - it "responds with status 401" do - download(path, env) do |response| + context "when no authentication is provided" do + it "responds with status 401 to downloads" do + download(path, {}) do |response| expect(response).to have_http_status(401) end end - context "when the user is IP banned" do - it "responds with status 401" do - expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true) - allow_any_instance_of(Rack::Request).to receive(:ip).and_return('1.2.3.4') - - clone_get(path, env) - + it "responds with status 401 to uploads" do + upload(path, {}) do |response| expect(response).to have_http_status(401) end end end - context "when authentication succeeds" do - let(:env) { { user: user.username, password: user.password } } + context "when username and password are provided" do + let(:env) { { user: user.username, password: 'nope' } } - context "when the user has access to the project" do - before do - project.team << [user, :master] + context "when authentication fails" do + it "responds with status 401" do + download(path, env) do |response| + expect(response).to have_http_status(401) + end end - context "when the user is blocked" do - it "responds with status 404" do - user.block - project.team << [user, :master] + context "when the user is IP banned" do + it "responds with status 401" do + expect(Rack::Attack::Allow2Ban).to receive(:filter).and_return(true) + allow_any_instance_of(Rack::Request).to receive(:ip).and_return('1.2.3.4') - download(path, env) do |response| - expect(response).to have_http_status(404) - end + clone_get(path, env) + + expect(response).to have_http_status(401) end end + end - context "when the user isn't blocked" do - it "downloads get status 200" do - expect(Rack::Attack::Allow2Ban).to receive(:reset) + context "when authentication succeeds" do + let(:env) { { user: user.username, password: user.password } } - clone_get(path, env) + context "when the user has access to the project" do + before do + project.team << [user, :master] + end - expect(response).to have_http_status(200) + context "when the user is blocked" do + it "responds with status 404" do + user.block + project.team << [user, :master] + + download(path, env) do |response| + expect(response).to have_http_status(404) + end + end end - it "uploads get status 200" do - upload(path, env) do |response| + context "when the user isn't blocked" do + it "downloads get status 200" do + expect(Rack::Attack::Allow2Ban).to receive(:reset) + + clone_get(path, env) + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + it "uploads get status 200" do + upload(path, env) do |response| + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end end end - end - context "when an oauth token is provided" do - before do - application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) - @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id) + context "when an oauth token is provided" do + before do + application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) + @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id) + end + + it "downloads get status 200" do + clone_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token + + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + it "uploads get status 401 (no project existence information leak)" do + push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token + + expect(response).to have_http_status(401) + end end - it "downloads get status 200" do - clone_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token + context 'when user has 2FA enabled' do + let(:user) { create(:user, :two_factor) } + let(:access_token) { create(:personal_access_token, user: user) } - expect(response).to have_http_status(200) + before do + project.team << [user, :master] + end + + context 'when username and password are provided' do + it 'rejects the clone attempt' do + download("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response| + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') + end + end + + it 'rejects the push attempt' do + upload("#{project.path_with_namespace}.git", user: user.username, password: user.password) do |response| + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') + end + end + end + + context 'when username and personal access token are provided' do + it 'allows clones' do + download("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response| + expect(response).to have_http_status(200) + end + end + + it 'allows pushes' do + upload("#{project.path_with_namespace}.git", user: user.username, password: access_token.token) do |response| + expect(response).to have_http_status(200) + end + end + end end - it "uploads get status 401 (no project existence information leak)" do - push_get "#{project.path_with_namespace}.git", user: 'oauth2', password: @token.token + context "when blank password attempts follow a valid login" do + def attempt_login(include_password) + password = include_password ? user.password : "" + clone_get path, user: user.username, password: password + response.status + end - expect(response).to have_http_status(401) + it "repeated attempts followed by successful attempt" do + options = Gitlab.config.rack_attack.git_basic_auth + maxretry = options[:maxretry] - 1 + ip = '1.2.3.4' + + allow_any_instance_of(Rack::Request).to receive(:ip).and_return(ip) + Rack::Attack::Allow2Ban.reset(ip, options) + + maxretry.times.each do + expect(attempt_login(false)).to eq(401) + end + + expect(attempt_login(true)).to eq(200) + expect(Rack::Attack::Allow2Ban.banned?(ip)).to be_falsey + + maxretry.times.each do + expect(attempt_login(false)).to eq(401) + end + + Rack::Attack::Allow2Ban.reset(ip, options) + end end end - context "when blank password attempts follow a valid login" do - def attempt_login(include_password) - password = include_password ? user.password : "" - clone_get path, user: user.username, password: password - response.status + context "when the user doesn't have access to the project" do + it "downloads get status 404" do + download(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(404) + end + end + + it "uploads get status 404" do + upload(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(404) + end end + end + end + end - it "repeated attempts followed by successful attempt" do - options = Gitlab.config.rack_attack.git_basic_auth - maxretry = options[:maxretry] - 1 - ip = '1.2.3.4' + context "when a gitlab ci token is provided" do + let(:build) { create(:ci_build, :running) } + let(:project) { build.project } + let(:other_project) { create(:empty_project) } - allow_any_instance_of(Rack::Request).to receive(:ip).and_return(ip) - Rack::Attack::Allow2Ban.reset(ip, options) + before do + project.project_feature.update_attributes(builds_access_level: ProjectFeature::ENABLED) + end - maxretry.times.each do - expect(attempt_login(false)).to eq(401) - end + context 'when build created by system is authenticated' do + it "downloads get status 200" do + clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token - expect(attempt_login(true)).to eq(200) - expect(Rack::Attack::Allow2Ban.banned?(ip)).to be_falsey + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end - maxretry.times.each do - expect(attempt_login(false)).to eq(401) - end + it "uploads get status 401 (no project existence information leak)" do + push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token - Rack::Attack::Allow2Ban.reset(ip, options) - end + expect(response).to have_http_status(401) + end + + it "downloads from other project get status 404" do + clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + + expect(response).to have_http_status(404) end end - context "when the user doesn't have access to the project" do - it "downloads get status 404" do - download(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(404) - end + context 'and build created by' do + before do + build.update(user: user) + project.team << [user, :reporter] end - it "uploads get status 200 (because Git hooks do the real check)" do - upload(path, user: user.username, password: user.password) do |response| + shared_examples 'can download code only' do + it 'downloads get status 200' do + clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + it 'uploads get status 403' do + push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + + expect(response).to have_http_status(401) end end - end - end - end - context "when a gitlab ci token is provided" do - let(:token) { 123 } - let(:project) { FactoryGirl.create :empty_project } + context 'administrator' do + let(:user) { create(:admin) } - before do - project.update_attributes(runners_token: token, builds_enabled: true) - end + it_behaves_like 'can download code only' - it "downloads get status 200" do - clone_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: token + it 'downloads from other project get status 403' do + clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token - expect(response).to have_http_status(200) - end + expect(response).to have_http_status(403) + end + end + + context 'regular user' do + let(:user) { create(:user) } - it "uploads get status 401 (no project existence information leak)" do - push_get "#{project.path_with_namespace}.git", user: 'gitlab-ci-token', password: token + it_behaves_like 'can download code only' - expect(response).to have_http_status(401) + it 'downloads from other project get status 404' do + clone_get "#{other_project.path_with_namespace}.git", user: 'gitlab-ci-token', password: build.token + + expect(response).to have_http_status(404) + end + end + end end end end - end - context "when the project path doesn't end in .git" do - context "GET info/refs" do - let(:path) { "/#{project.path_with_namespace}/info/refs" } + context "when the project path doesn't end in .git" do + context "GET info/refs" do + let(:path) { "/#{project.path_with_namespace}/info/refs" } - context "when no params are added" do - before { get path } + context "when no params are added" do + before { get path } - it "redirects to the .git suffix version" do - expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs") + it "redirects to the .git suffix version" do + expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs") + end end - end - context "when the upload-pack service is requested" do - let(:params) { { service: 'git-upload-pack' } } - before { get path, params } + context "when the upload-pack service is requested" do + let(:params) { { service: 'git-upload-pack' } } + before { get path, params } - it "redirects to the .git suffix version" do - expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + it "redirects to the .git suffix version" do + expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + end end - end - context "when the receive-pack service is requested" do - let(:params) { { service: 'git-receive-pack' } } - before { get path, params } + context "when the receive-pack service is requested" do + let(:params) { { service: 'git-receive-pack' } } + before { get path, params } - it "redirects to the .git suffix version" do - expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + it "redirects to the .git suffix version" do + expect(response).to redirect_to("/#{project.path_with_namespace}.git/info/refs?service=#{params[:service]}") + end end - end - context "when the params are anything else" do - let(:params) { { service: 'git-implode-pack' } } - before { get path, params } + context "when the params are anything else" do + let(:params) { { service: 'git-implode-pack' } } - it "redirects to the sign-in page" do - expect(response).to redirect_to(new_user_session_path) + it "fails to find a route" do + expect { get(path, params) }.to raise_error(ActionController::RoutingError) + end end end - end - context "POST git-upload-pack" do - it "fails to find a route" do - expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + context "POST git-upload-pack" do + it "fails to find a route" do + expect { clone_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + end end - end - context "POST git-receive-pack" do - it "failes to find a route" do - expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + context "POST git-receive-pack" do + it "failes to find a route" do + expect { push_post(project.path_with_namespace) }.to raise_error(ActionController::RoutingError) + end end end - end - context "retrieving an info/refs file" do - before { project.update_attribute(:visibility_level, Project::PUBLIC) } + context "retrieving an info/refs file" do + before { project.update_attribute(:visibility_level, Project::PUBLIC) } - context "when the file exists" do - before do - # Provide a dummy file in its place - allow_any_instance_of(Repository).to receive(:blob_at).and_call_original - allow_any_instance_of(Repository).to receive(:blob_at).with('5937ac0a7beb003549fc5fd26fc247adbce4a52e', 'info/refs') do - Gitlab::Git::Blob.find(project.repository, 'master', '.gitignore') - end + context "when the file exists" do + before do + # Provide a dummy file in its place + allow_any_instance_of(Repository).to receive(:blob_at).and_call_original + allow_any_instance_of(Repository).to receive(:blob_at).with('b83d6e391c22777fca1ed3012fce84f633d7fed0', 'info/refs') do + Gitlab::Git::Blob.find(project.repository, 'master', 'bar/branch-test.txt') + end - get "/#{project.path_with_namespace}/blob/master/info/refs" - end + get "/#{project.path_with_namespace}/blob/master/info/refs" + end - it "returns the file" do - expect(response).to have_http_status(200) + it "returns the file" do + expect(response).to have_http_status(200) + end end - end - context "when the file does not exist" do - before { get "/#{project.path_with_namespace}/blob/master/info/refs" } + context "when the file does not exist" do + before { get "/#{project.path_with_namespace}/blob/master/info/refs" } - it "returns not found" do - expect(response).to have_http_status(404) + it "returns not found" do + expect(response).to have_http_status(404) + end end end end - def clone_get(project, options={}) - get "/#{project}/info/refs", { service: 'git-upload-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token)) - end - - def clone_post(project, options={}) - post "/#{project}/git-upload-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token)) - end + describe "User with LDAP identity" do + let(:user) { create(:omniauth_user, extern_uid: dn) } + let(:dn) { 'uid=john,ou=people,dc=example,dc=com' } - def push_get(project, options={}) - get "/#{project}/info/refs", { service: 'git-receive-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token)) - end - - def push_post(project, options={}) - post "/#{project}/git-receive-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token)) - end - - def download(project, user: nil, password: nil, spnego_request_token: nil) - args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }] + before do + allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true) + allow(Gitlab::LDAP::Authentication).to receive(:login).and_return(nil) + allow(Gitlab::LDAP::Authentication).to receive(:login).with(user.username, user.password).and_return(user) + end - clone_get(*args) - yield response + context "when authentication fails" do + context "when no authentication is provided" do + it "responds with status 401" do + download('doesnt/exist.git') do |response| + expect(response).to have_http_status(401) + end + end + end - clone_post(*args) - yield response - end + context "when username and invalid password are provided" do + it "responds with status 401" do + download('doesnt/exist.git', user: user.username, password: "nope") do |response| + expect(response).to have_http_status(401) + end + end + end + end - def upload(project, user: nil, password: nil, spnego_request_token: nil) - args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }] + context "when authentication succeeds" do + context "when the project doesn't exist" do + it "responds with status 404" do + download('/doesnt/exist.git', user: user.username, password: user.password) do |response| + expect(response).to have_http_status(404) + end + end + end - push_get(*args) - yield response + context "when the project exists" do + let(:project) { create(:project, path: 'project.git-project') } - push_post(*args) - yield response - end + before do + project.team << [user, :master] + end - def auth_env(user, password, spnego_request_token) - env = {} - if user && password - env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password) - elsif spnego_request_token - env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}" + it "responds with status 200" do + clone_get(path, user: user.username, password: user.password) do |response| + expect(response).to have_http_status(200) + end + end + end end - - env end end diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index c6172b9cc7d..f0ef155bd7b 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -22,33 +22,54 @@ describe JwtController do context 'when using authorized request' do context 'using CI token' do - let(:project) { create(:empty_project, runners_token: 'token', builds_enabled: builds_enabled) } - let(:headers) { { authorization: credentials('gitlab-ci-token', project.runners_token) } } - - subject! { get '/jwt/auth', parameters, headers } + let(:build) { create(:ci_build, :running) } + let(:project) { build.project } + let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } } context 'project with enabled CI' do - let(:builds_enabled) { true } + subject! { get '/jwt/auth', parameters, headers } it { expect(service_class).to have_received(:new).with(project, nil, parameters) } end context 'project with disabled CI' do - let(:builds_enabled) { false } + before do + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + end + + subject! { get '/jwt/auth', parameters, headers } - it { expect(response).to have_http_status(403) } + it { expect(response).to have_http_status(401) } end end context 'using User login' do let(:user) { create(:user) } - let(:headers) { { authorization: credentials('user', 'password') } } - - before { expect(Gitlab::Auth).to receive(:find_with_user_password).with('user', 'password').and_return(user) } + let(:headers) { { authorization: credentials(user.username, user.password) } } subject! { get '/jwt/auth', parameters, headers } it { expect(service_class).to have_received(:new).with(nil, user, parameters) } + + context 'when user has 2FA enabled' do + let(:user) { create(:user, :two_factor) } + + context 'without personal token' do + it 'rejects the authorization attempt' do + expect(response).to have_http_status(401) + expect(response.body).to include('You have 2FA enabled, please use a personal access token for Git over HTTP') + end + end + + context 'with personal token' do + let(:access_token) { create(:personal_access_token, user: user) } + let(:headers) { { authorization: credentials(user.username, access_token.token) } } + + it 'rejects the authorization attempt' do + expect(response).to have_http_status(200) + end + end + end end context 'using invalid login' do @@ -56,7 +77,7 @@ describe JwtController do subject! { get '/jwt/auth', parameters, headers } - it { expect(response).to have_http_status(403) } + it { expect(response).to have_http_status(401) } end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 93d2bc160cc..dbdf83a0dff 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -describe Gitlab::Lfs::Router do +describe 'Git LFS API and storage' do + include WorkhorseHelpers + let(:user) { create(:user) } let!(:lfs_object) { create(:lfs_object, :with_file) } @@ -12,6 +14,7 @@ describe Gitlab::Lfs::Router do end let(:authorization) { } let(:sendfile) { } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } let(:sample_oid) { lfs_object.oid } let(:sample_size) { lfs_object.size } @@ -31,10 +34,11 @@ describe Gitlab::Lfs::Router do 'operation' => 'upload' } end + let(:authorization) { authorize_user } before do allow(Gitlab.config.lfs).to receive(:enabled).and_return(false) - post_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers end it 'responds with 501' do @@ -43,6 +47,113 @@ describe Gitlab::Lfs::Router do end end + context 'project specific LFS settings' do + let(:project) { create(:empty_project) } + let(:body) do + { + 'objects' => [ + { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', + 'size' => 1575078 + }, + { 'oid' => sample_oid, + 'size' => sample_size + } + ], + 'operation' => 'upload' + } + end + let(:authorization) { authorize_user } + + context 'with LFS disabled globally' do + before do + project.team << [user, :master] + allow(Gitlab.config.lfs).to receive(:enabled).and_return(false) + end + + describe 'LFS disabled in project' do + before do + project.update_attribute(:lfs_enabled, false) + end + + it 'responds with a 501 message on upload' do + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers + + expect(response).to have_http_status(501) + end + + it 'responds with a 501 message on download' do + get "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", nil, headers + + expect(response).to have_http_status(501) + end + end + + describe 'LFS enabled in project' do + before do + project.update_attribute(:lfs_enabled, true) + end + + it 'responds with a 501 message on upload' do + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers + + expect(response).to have_http_status(501) + end + + it 'responds with a 501 message on download' do + get "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", nil, headers + + expect(response).to have_http_status(501) + end + end + end + + context 'with LFS enabled globally' do + before do + project.team << [user, :master] + enable_lfs + end + + describe 'LFS disabled in project' do + before do + project.update_attribute(:lfs_enabled, false) + end + + it 'responds with a 403 message on upload' do + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers + + expect(response).to have_http_status(403) + expect(json_response).to include('message' => 'Access forbidden. Check your access level.') + end + + it 'responds with a 403 message on download' do + get "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", nil, headers + + expect(response).to have_http_status(403) + expect(json_response).to include('message' => 'Access forbidden. Check your access level.') + end + end + + describe 'LFS enabled in project' do + before do + project.update_attribute(:lfs_enabled, true) + end + + it 'responds with a 200 message on upload' do + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers + + expect(response).to have_http_status(200) + expect(json_response['objects'].first['size']).to eq(1575078) + end + + it 'responds with a 200 message on download' do + get "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", nil, headers + + expect(response).to have_http_status(200) + end + end + end + end + describe 'deprecated API' do let(:project) { create(:empty_project) } @@ -71,8 +182,9 @@ describe Gitlab::Lfs::Router do end context 'when handling lfs request using deprecated API' do + let(:authorization) { authorize_user } before do - post_json "#{project.http_url_to_repo}/info/lfs/objects", nil, headers + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects", nil, headers end it_behaves_like 'a deprecated' @@ -118,8 +230,8 @@ describe Gitlab::Lfs::Router do project.lfs_objects << lfs_object end - it 'responds with status 403' do - expect(response).to have_http_status(403) + it 'responds with status 404' do + expect(response).to have_http_status(404) end end @@ -133,22 +245,106 @@ describe Gitlab::Lfs::Router do end end - context 'when CI is authorized' do - let(:authorization) { authorize_ci_project } + context 'when deploy key is authorized' do + let(:key) { create(:deploy_key) } + let(:authorization) { authorize_deploy_key } let(:update_permissions) do + project.deploy_keys << key project.lfs_objects << lfs_object end it_behaves_like 'responds with a file' end + + describe 'when using a user key' do + let(:authorization) { authorize_user_key } + + context 'when user allowed' do + let(:update_permissions) do + project.team << [user, :master] + project.lfs_objects << lfs_object + end + + it_behaves_like 'responds with a file' + end + + context 'when user not allowed' do + let(:update_permissions) do + project.lfs_objects << lfs_object + end + + it 'responds with status 404' do + expect(response).to have_http_status(404) + end + end + end + + context 'when build is authorized as' do + let(:authorization) { authorize_ci_project } + + shared_examples 'can download LFS only from own projects' do + context 'for own project' do + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + + let(:update_permissions) do + project.team << [user, :reporter] + project.lfs_objects << lfs_object + end + + it_behaves_like 'responds with a file' + end + + context 'for other project' do + let(:other_project) { create(:empty_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } + + let(:update_permissions) do + project.lfs_objects << lfs_object + end + + it 'rejects downloading code' do + expect(response).to have_http_status(other_project_status) + end + end + end + + context 'administrator' do + let(:user) { create(:admin) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it_behaves_like 'can download LFS only from own projects' do + # We render 403, because administrator does have normally access + let(:other_project_status) { 403 } + end + end + + context 'regular user' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it_behaves_like 'can download LFS only from own projects' do + # We render 404, to prevent data leakage about existence of the project + let(:other_project_status) { 404 } + end + end + + context 'does not have user' do + let(:build) { create(:ci_build, :running, pipeline: pipeline) } + + it_behaves_like 'can download LFS only from own projects' do + # We render 404, to prevent data leakage about existence of the project + let(:other_project_status) { 404 } + end + end + end end context 'without required headers' do let(:authorization) { authorize_user } - it 'responds with status 403' do - expect(response).to have_http_status(403) + it 'responds with status 404' do + expect(response).to have_http_status(404) end end end @@ -162,7 +358,7 @@ describe Gitlab::Lfs::Router do enable_lfs update_lfs_permissions update_user_permissions - post_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers + post_lfs_json "#{project.http_url_to_repo}/info/lfs/objects/batch", body, headers end describe 'download' do @@ -304,10 +500,10 @@ describe Gitlab::Lfs::Router do end context 'when user does is not member of the project' do - let(:role) { :guest } + let(:update_user_permissions) { nil } - it 'responds with 403' do - expect(response).to have_http_status(403) + it 'responds with 404' do + expect(response).to have_http_status(404) end end @@ -320,10 +516,62 @@ describe Gitlab::Lfs::Router do end end - context 'when CI is authorized' do + context 'when build is authorized as' do let(:authorization) { authorize_ci_project } - it_behaves_like 'an authorized requests' + let(:update_lfs_permissions) do + project.lfs_objects << lfs_object + end + + shared_examples 'can download LFS only from own projects' do + context 'for own project' do + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + + let(:update_user_permissions) do + project.team << [user, :reporter] + end + + it_behaves_like 'an authorized requests' + end + + context 'for other project' do + let(:other_project) { create(:empty_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } + + it 'rejects downloading code' do + expect(response).to have_http_status(other_project_status) + end + end + end + + context 'administrator' do + let(:user) { create(:admin) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it_behaves_like 'can download LFS only from own projects' do + # We render 403, because administrator does have normally access + let(:other_project_status) { 403 } + end + end + + context 'regular user' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it_behaves_like 'can download LFS only from own projects' do + # We render 404, to prevent data leakage about existence of the project + let(:other_project_status) { 404 } + end + end + + context 'does not have user' do + let(:build) { create(:ci_build, :running, pipeline: pipeline) } + + it_behaves_like 'can download LFS only from own projects' do + # We render 404, to prevent data leakage about existence of the project + let(:other_project_status) { 404 } + end + end end context 'when user is not authenticated' do @@ -472,11 +720,37 @@ describe Gitlab::Lfs::Router do end end - context 'when CI is authorized' do + context 'when build is authorized' do let(:authorization) { authorize_ci_project } - it 'responds with 401' do - expect(response).to have_http_status(401) + context 'build has an user' do + let(:user) { create(:user) } + + context 'tries to push to own project' do + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end + + context 'tries to push to other project' do + let(:other_project) { create(:empty_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end + end + + context 'does not have user' do + let(:build) { create(:ci_build, :running, pipeline: pipeline) } + + it 'responds with 401' do + expect(response).to have_http_status(401) + end end end end @@ -498,18 +772,11 @@ describe Gitlab::Lfs::Router do end end end - - context 'when CI is authorized' do - let(:authorization) { authorize_ci_project } - - it 'responds with status 403' do - expect(response).to have_http_status(401) - end - end end describe 'unsupported' do let(:project) { create(:empty_project) } + let(:authorization) { authorize_user } let(:body) do { 'operation' => 'other', 'objects' => [ @@ -553,11 +820,11 @@ describe Gitlab::Lfs::Router do context 'and request is sent with a malformed headers' do before do - put_finalize('cat /etc/passwd') + put_finalize('/etc/passwd') end it 'does not recognize it as a valid lfs command' do - expect(response).to have_http_status(403) + expect(response).to have_http_status(401) end end end @@ -582,6 +849,16 @@ describe Gitlab::Lfs::Router do expect(response).to have_http_status(403) end end + + context 'and request is sent with a malformed headers' do + before do + put_finalize('/etc/passwd') + end + + it 'does not recognize it as a valid lfs command' do + expect(response).to have_http_status(403) + end + end end describe 'to one project' do @@ -595,6 +872,12 @@ describe Gitlab::Lfs::Router do project.team << [user, :developer] end + context 'and the request bypassed workhorse' do + it 'raises an exception' do + expect { put_authorize(verified: false) }.to raise_error JWT::DecodeError + end + end + context 'and request is sent by gitlab-workhorse to authorize the request' do before do put_authorize @@ -604,6 +887,10 @@ describe Gitlab::Lfs::Router do expect(response).to have_http_status(200) end + it 'uses the gitlab-workhorse content type' do + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + it 'responds with status 200, location of lfs store and object details' do expect(json_response['StoreLFSPath']).to eq("#{Gitlab.config.shared.path}/lfs-objects/tmp/upload") expect(json_response['LfsOid']).to eq(sample_oid) @@ -624,17 +911,74 @@ describe Gitlab::Lfs::Router do expect(lfs_object.projects.pluck(:id)).to include(project.id) end end + + context 'invalid tempfiles' do + it 'rejects slashes in the tempfile name (path traversal' do + put_finalize('foo/bar') + expect(response).to have_http_status(403) + end + + it 'rejects tempfile names that do not start with the oid' do + put_finalize("foo#{sample_oid}") + expect(response).to have_http_status(403) + end + end end describe 'and user does not have push access' do + before do + project.team << [user, :reporter] + end + it_behaves_like 'forbidden' end end - context 'when CI is authenticated' do + context 'when build is authorized' do let(:authorization) { authorize_ci_project } - it_behaves_like 'unauthorized' + context 'build has an user' do + let(:user) { create(:user) } + + context 'tries to push to own project' do + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + before do + project.team << [user, :developer] + put_authorize + end + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end + + context 'tries to push to other project' do + let(:other_project) { create(:empty_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + before do + put_authorize + end + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end + end + + context 'does not have user' do + let(:build) { create(:ci_build, :running, pipeline: pipeline) } + + before do + put_authorize + end + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end end context 'for unauthenticated' do @@ -691,10 +1035,42 @@ describe Gitlab::Lfs::Router do end end - context 'when CI is authenticated' do + context 'when build is authorized' do let(:authorization) { authorize_ci_project } - it_behaves_like 'unauthorized' + before do + put_authorize + end + + context 'build has an user' do + let(:user) { create(:user) } + + context 'tries to push to own project' do + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end + + context 'tries to push to other project' do + let(:other_project) { create(:empty_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: other_project) } + let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) } + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end + end + + context 'does not have user' do + let(:build) { create(:ci_build, :running, pipeline: pipeline) } + + it 'responds with 401' do + expect(response).to have_http_status(401) + end + end end context 'for unauthenticated' do @@ -727,8 +1103,11 @@ describe Gitlab::Lfs::Router do end end - def put_authorize - put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}/authorize", nil, headers + def put_authorize(verified: true) + authorize_headers = headers + authorize_headers.merge!(workhorse_internal_api_request_header) if verified + + put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}/authorize", nil, authorize_headers end def put_finalize(lfs_tmp = lfs_tmp_file) @@ -746,20 +1125,28 @@ describe Gitlab::Lfs::Router do end def authorize_ci_project - ActionController::HttpAuthentication::Basic.encode_credentials('gitlab-ci-token', project.runners_token) + ActionController::HttpAuthentication::Basic.encode_credentials('gitlab-ci-token', build.token) end def authorize_user ActionController::HttpAuthentication::Basic.encode_credentials(user.username, user.password) end + def authorize_deploy_key + ActionController::HttpAuthentication::Basic.encode_credentials("lfs+deploy-key-#{key.id}", Gitlab::LfsToken.new(key).token) + end + + def authorize_user_key + ActionController::HttpAuthentication::Basic.encode_credentials(user.username, Gitlab::LfsToken.new(user).token) + end + def fork_project(project, user, object = nil) allow(RepositoryForkWorker).to receive(:perform_async).and_return(true) Projects::ForkService.new(project, user, {}).execute end - def post_json(url, body = nil, headers = nil) - post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => 'application/json')) + def post_lfs_json(url, body = nil, headers = nil) + post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => 'application/vnd.git-lfs+json')) end def json_response diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb new file mode 100644 index 00000000000..e02f0eacc93 --- /dev/null +++ b/spec/requests/projects/artifacts_controller_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe Projects::ArtifactsController do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do + before do + project.team << [user, :developer] + + login_as(user) + end + + def path_from_ref( + ref = pipeline.ref, job = build.name, path = 'browse') + latest_succeeded_namespace_project_artifacts_path( + project.namespace, + project, + [ref, path].join('/'), + job: job) + end + + context 'cannot find the build' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get path_from_ref('TAIL', build.name) + end + + it_behaves_like 'not found' + end + + context 'has no such build' do + before do + get path_from_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + + context 'has no path' do + before do + get path_from_ref(pipeline.sha, build.name, '') + end + + it_behaves_like 'not found' + end + end + + context 'found the build and redirect' do + shared_examples 'redirect to the build' do + it 'redirects' do + path = browse_namespace_project_build_artifacts_path( + project.namespace, + project, + build) + + expect(response).to redirect_to(path) + end + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get path_from_ref('master') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get path_from_ref('improve/awesome') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name and path containing slashes' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get path_from_ref('improve/awesome', build.name, 'file/README.md') + end + + it 'redirects' do + path = file_namespace_project_build_artifacts_path( + project.namespace, + project, + build, + 'README.md') + + expect(response).to redirect_to(path) + end + end + end + end +end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index b941e78f983..2322430d212 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -60,7 +60,7 @@ end # project GET /:id(.:format) projects#show # PUT /:id(.:format) projects#update # DELETE /:id(.:format) projects#destroy -# markdown_preview_project POST /:id/markdown_preview(.:format) projects#markdown_preview +# preview_markdown_project POST /:id/preview_markdown(.:format) projects#preview_markdown describe ProjectsController, 'routing' do it 'to #create' do expect(post('/projects')).to route_to('projects#create') @@ -91,9 +91,9 @@ describe ProjectsController, 'routing' do expect(delete('/gitlab/gitlabhq')).to route_to('projects#destroy', namespace_id: 'gitlab', id: 'gitlabhq') end - it 'to #markdown_preview' do - expect(post('/gitlab/gitlabhq/markdown_preview')).to( - route_to('projects#markdown_preview', namespace_id: 'gitlab', id: 'gitlabhq') + it 'to #preview_markdown' do + expect(post('/gitlab/gitlabhq/preview_markdown')).to( + route_to('projects#preview_markdown', namespace_id: 'gitlab', id: 'gitlabhq') ) end end @@ -337,7 +337,7 @@ describe Projects::CommitsController, 'routing' do end it 'to #show' do - expect(get('/gitlab/gitlabhq/commits/master.atom')).to route_to('projects/commits#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', format: 'atom') + expect(get('/gitlab/gitlabhq/commits/master.atom')).to route_to('projects/commits#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master.atom') end end diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 1d4df9197f6..0ee1c811dfb 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -9,7 +9,9 @@ require 'spec_helper' # user_calendar_activities GET /u/:username/calendar_activities(.:format) describe UsersController, "routing" do it "to #show" do - expect(get("/u/User")).to route_to('users#show', username: 'User') + allow(User).to receive(:find_by).and_return(true) + + expect(get("/User")).to route_to('users#show', username: 'User') end it "to #groups" do @@ -107,21 +109,28 @@ describe HelpController, "routing" do end it 'to #show' do - path = '/help/markdown/markdown.md' + path = '/help/user/markdown.md' expect(get(path)).to route_to('help#show', - path: 'markdown/markdown', + path: 'user/markdown', format: 'md') path = '/help/workflow/protected_branches/protected_branches1.png' expect(get(path)).to route_to('help#show', path: 'workflow/protected_branches/protected_branches1', format: 'png') - + path = '/help/ui' expect(get(path)).to route_to('help#ui') end end +# koding GET /koding(.:format) koding#index +describe KodingController, "routing" do + it "to #index" do + expect(get("/koding")).to route_to('koding#index') + end +end + # profile_account GET /profile/account(.:format) profile#account # profile_history GET /profile/history(.:format) profile#history # profile_password PUT /profile/password(.:format) profile#password_update @@ -257,7 +266,9 @@ describe "Groups", "routing" do end it "also display group#show on the short path" do - expect(get('/1')).to route_to('namespaces#show', id: '1') + allow(Group).to receive(:find_by_path).and_return(true) + + expect(get('/1')).to route_to('groups#show', id: '1') end end diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb index 7cc71f706ce..c64df4979b0 100644 --- a/spec/services/auth/container_registry_authentication_service_spec.rb +++ b/spec/services/auth/container_registry_authentication_service_spec.rb @@ -6,8 +6,14 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do let(:current_params) { {} } let(:rsa_key) { OpenSSL::PKey::RSA.generate(512) } let(:payload) { JWT.decode(subject[:token], rsa_key).first } + let(:authentication_abilities) do + [ + :read_container_image, + :create_container_image + ] + end - subject { described_class.new(current_project, current_user, current_params).execute } + subject { described_class.new(current_project, current_user, current_params).execute(authentication_abilities: authentication_abilities) } before do allow(Gitlab.config.registry).to receive_messages(enabled: true, issuer: 'rspec', key: nil) @@ -189,13 +195,22 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do end end - context 'project authorization' do + context 'build authorized as user' do let(:current_project) { create(:empty_project) } + let(:current_user) { create(:user) } + let(:authentication_abilities) do + [ + :build_read_container_image, + :build_create_container_image + ] + end - context 'allow to use scope-less authentication' do - it_behaves_like 'a valid token' + before do + current_project.team << [current_user, :developer] end + it_behaves_like 'a valid token' + context 'allow to pull and push images' do let(:current_params) do { scope: "repository:#{current_project.path_with_namespace}:pull,push" } @@ -214,12 +229,44 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do context 'allow for public' do let(:project) { create(:empty_project, :public) } + it_behaves_like 'a pullable' end - context 'disallow for private' do + shared_examples 'pullable for being team member' do + context 'when you are not member' do + it_behaves_like 'an inaccessible' + end + + context 'when you are member' do + before do + project.team << [current_user, :developer] + end + + it_behaves_like 'a pullable' + end + end + + context 'for private' do let(:project) { create(:empty_project, :private) } - it_behaves_like 'an inaccessible' + + it_behaves_like 'pullable for being team member' + + context 'when you are admin' do + let(:current_user) { create(:admin) } + + context 'when you are not member' do + it_behaves_like 'an inaccessible' + end + + context 'when you are member' do + before do + project.team << [current_user, :developer] + end + + it_behaves_like 'a pullable' + end + end end end @@ -230,6 +277,11 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do context 'disallow for all' do let(:project) { create(:empty_project, :public) } + + before do + project.team << [current_user, :developer] + end + it_behaves_like 'an inaccessible' end end diff --git a/spec/services/boards/create_service_spec.rb b/spec/services/boards/create_service_spec.rb new file mode 100644 index 00000000000..fde807cc410 --- /dev/null +++ b/spec/services/boards/create_service_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Boards::CreateService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + + subject(:service) { described_class.new(project, double) } + + context 'when project does not have a board' do + it 'creates a new board' do + expect { service.execute }.to change(Board, :count).by(1) + end + + it 'creates default lists' do + board = service.execute + + expect(board.lists.size).to eq 2 + expect(board.lists.first).to be_backlog + expect(board.lists.last).to be_done + end + end + + context 'when project has a board' do + before do + create(:board, project: project) + end + + it 'does not create a new board' do + expect { service.execute }.not_to change(project.boards, :count) + end + end + end +end diff --git a/spec/services/boards/issues/create_service_spec.rb b/spec/services/boards/issues/create_service_spec.rb new file mode 100644 index 00000000000..360ee398f77 --- /dev/null +++ b/spec/services/boards/issues/create_service_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Boards::Issues::CreateService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + let(:label) { create(:label, project: project, name: 'in-progress') } + let!(:list) { create(:list, board: board, label: label, position: 0) } + + subject(:service) { described_class.new(project, user, board_id: board.id, list_id: list.id, title: 'New issue') } + + before do + project.team << [user, :developer] + end + + it 'delegates the create proceedings to Issues::CreateService' do + expect_any_instance_of(Issues::CreateService).to receive(:execute).once + + service.execute + end + + it 'creates a new issue' do + expect { service.execute }.to change(project.issues, :count).by(1) + end + + it 'adds the label of the list to the issue' do + issue = service.execute + + expect(issue.labels).to eq [label] + end + end +end diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb new file mode 100644 index 00000000000..7c206cf3ce7 --- /dev/null +++ b/spec/services/boards/issues/list_service_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +describe Boards::Issues::ListService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + + let(:bug) { create(:label, project: project, name: 'Bug') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } + let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } + let(:p3) { create(:label, title: 'P3', project: project, priority: 3) } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:list1) { create(:list, board: board, label: development, position: 0) } + let!(:list2) { create(:list, board: board, label: testing, position: 1) } + let!(:done) { create(:done_list, board: board) } + + let!(:opened_issue1) { create(:labeled_issue, project: project, labels: [bug]) } + let!(:opened_issue2) { create(:labeled_issue, project: project, labels: [p2]) } + let!(:reopened_issue1) { create(:issue, :reopened, project: project) } + + let!(:list1_issue1) { create(:labeled_issue, project: project, labels: [p2, development]) } + let!(:list1_issue2) { create(:labeled_issue, project: project, labels: [development]) } + let!(:list1_issue3) { create(:labeled_issue, project: project, labels: [development, p1]) } + let!(:list2_issue1) { create(:labeled_issue, project: project, labels: [testing]) } + + let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug]) } + let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3]) } + let!(:closed_issue3) { create(:issue, :closed, project: project) } + let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1]) } + + before do + project.team << [user, :developer] + end + + it 'delegates search to IssuesFinder' do + params = { board_id: board.id, id: list1.id } + + expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original + + described_class.new(project, user, params).execute + end + + context 'sets default order to priority' do + it 'returns opened issues when listing issues from Backlog' do + params = { board_id: board.id, id: backlog.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1] + end + + it 'returns closed issues when listing issues from Done' do + params = { board_id: board.id, id: done.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1] + end + + it 'returns opened issues that have label list applied when listing issues from a label list' do + params = { board_id: board.id, id: list1.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2] + end + end + + context 'with list that does not belong to the board' do + it 'raises an error' do + list = create(:list) + service = described_class.new(project, user, board_id: board.id, id: list.id) + + expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with invalid list id' do + it 'raises an error' do + service = described_class.new(project, user, board_id: board.id, id: nil) + + expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb new file mode 100644 index 00000000000..c43b2aec490 --- /dev/null +++ b/spec/services/boards/issues/move_service_spec.rb @@ -0,0 +1,144 @@ +require 'spec_helper' + +describe Boards::Issues::MoveService, services: true do + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:board1) { create(:board, project: project) } + + let(:bug) { create(:label, project: project, name: 'Bug') } + let(:development) { create(:label, project: project, name: 'Development') } + let(:testing) { create(:label, project: project, name: 'Testing') } + + let!(:backlog) { create(:backlog_list, board: board1) } + let!(:list1) { create(:list, board: board1, label: development, position: 0) } + let!(:list2) { create(:list, board: board1, label: testing, position: 1) } + let!(:done) { create(:done_list, board: board1) } + + before do + project.team << [user, :developer] + end + + context 'when moving from backlog' do + it 'adds the label of the list it goes to' do + issue = create(:labeled_issue, project: project, labels: [bug]) + params = { board_id: board1.id, from_list_id: backlog.id, to_list_id: list1.id } + + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, development) + end + end + + context 'when moving to backlog' do + it 'removes all list-labels' do + issue = create(:labeled_issue, project: project, labels: [bug, development, testing]) + params = { board_id: board1.id, from_list_id: list1.id, to_list_id: backlog.id } + + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug) + end + end + + context 'when moving from backlog to done' do + it 'closes the issue' do + issue = create(:labeled_issue, project: project, labels: [bug]) + params = { board_id: board1.id, from_list_id: backlog.id, to_list_id: done.id } + + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_closed + end + end + + context 'when moving an issue between lists' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } } + + it 'delegates the label changes to Issues::UpdateService' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'removes the label from the list it came from and adds the label of the list it goes to' do + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, testing) + end + end + + context 'when moving to done' do + let(:board2) { create(:board, project: project) } + let(:regression) { create(:label, project: project, name: 'Regression') } + let!(:list3) { create(:list, board: board2, label: regression, position: 1) } + + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development, testing, regression]) } + let(:params) { { board_id: board1.id, from_list_id: list2.id, to_list_id: done.id } } + + it 'delegates the close proceedings to Issues::CloseService' do + expect_any_instance_of(Issues::CloseService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'removes all list-labels from project boards and close the issue' do + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_closed + end + end + + context 'when moving from done' do + let(:issue) { create(:labeled_issue, :closed, project: project, labels: [bug]) } + let(:params) { { board_id: board1.id, from_list_id: done.id, to_list_id: list2.id } } + + it 'delegates the re-open proceedings to Issues::ReopenService' do + expect_any_instance_of(Issues::ReopenService).to receive(:execute).with(issue).once + + described_class.new(project, user, params).execute(issue) + end + + it 'adds the label of the list it goes to and reopen the issue' do + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug, testing) + expect(issue).to be_reopened + end + end + + context 'when moving from done to backlog' do + it 'reopens the issue' do + issue = create(:labeled_issue, :closed, project: project, labels: [bug]) + params = { board_id: board1.id, from_list_id: done.id, to_list_id: backlog.id } + + described_class.new(project, user, params).execute(issue) + issue.reload + + expect(issue.labels).to contain_exactly(bug) + expect(issue).to be_reopened + end + end + + context 'when moving to same list' do + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } } + + it 'returns false' do + expect(described_class.new(project, user, params).execute(issue)).to eq false + end + + it 'keeps issues labels' do + described_class.new(project, user, params).execute(issue) + + expect(issue.reload.labels).to contain_exactly(bug, development) + end + end + end +end diff --git a/spec/services/boards/list_service_spec.rb b/spec/services/boards/list_service_spec.rb new file mode 100644 index 00000000000..dff33e4bcbb --- /dev/null +++ b/spec/services/boards/list_service_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Boards::ListService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + + subject(:service) { described_class.new(project, double) } + + context 'when project does not have a board' do + it 'creates a new project board' do + expect { service.execute }.to change(project.boards, :count).by(1) + end + + it 'delegates the project board creation to Boards::CreateService' do + expect_any_instance_of(Boards::CreateService).to receive(:execute).once + + service.execute + end + end + + context 'when project has a board' do + before do + create(:board, project: project) + end + + it 'does not create a new board' do + expect { service.execute }.not_to change(project.boards, :count) + end + end + + it 'returns project boards' do + board = create(:board, project: project) + + expect(service.execute).to match_array [board] + end + end +end diff --git a/spec/services/boards/lists/create_service_spec.rb b/spec/services/boards/lists/create_service_spec.rb new file mode 100644 index 00000000000..e7806add916 --- /dev/null +++ b/spec/services/boards/lists/create_service_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Boards::Lists::CreateService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + let(:label) { create(:label, project: project, name: 'in-progress') } + + subject(:service) { described_class.new(project, user, label_id: label.id) } + + context 'when board lists is empty' do + it 'creates a new list at beginning of the list' do + list = service.execute(board) + + expect(list.position).to eq 0 + end + end + + context 'when board lists has backlog, and done lists' do + it 'creates a new list at beginning of the list' do + list = service.execute(board) + + expect(list.position).to eq 0 + end + end + + context 'when board lists has labels lists' do + it 'creates a new list at end of the lists' do + create(:list, board: board, position: 0) + create(:list, board: board, position: 1) + + list = service.execute(board) + + expect(list.position).to eq 2 + end + end + + context 'when board lists has backlog, label and done lists' do + it 'creates a new list at end of the label lists' do + list1 = create(:list, board: board, position: 0) + + list2 = service.execute(board) + + expect(list1.reload.position).to eq 0 + expect(list2.reload.position).to eq 1 + end + end + + context 'when provided label does not belongs to the project' do + it 'raises an error' do + label = create(:label, name: 'in-development') + service = described_class.new(project, user, label_id: label.id) + + expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/boards/lists/destroy_service_spec.rb b/spec/services/boards/lists/destroy_service_spec.rb new file mode 100644 index 00000000000..628caf03476 --- /dev/null +++ b/spec/services/boards/lists/destroy_service_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Boards::Lists::DestroyService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + + context 'when list type is label' do + it 'removes list from board' do + list = create(:list, board: board) + service = described_class.new(project, user) + + expect { service.execute(list) }.to change(board.lists, :count).by(-1) + end + + it 'decrements position of higher lists' do + backlog = board.backlog_list + development = create(:list, board: board, position: 0) + review = create(:list, board: board, position: 1) + staging = create(:list, board: board, position: 2) + done = board.done_list + + described_class.new(project, user).execute(development) + + expect(backlog.reload.position).to be_nil + expect(review.reload.position).to eq 0 + expect(staging.reload.position).to eq 1 + expect(done.reload.position).to be_nil + end + end + + it 'does not remove list from board when list type is backlog' do + list = board.backlog_list + service = described_class.new(project, user) + + expect { service.execute(list) }.not_to change(board.lists, :count) + end + + it 'does not remove list from board when list type is done' do + list = board.done_list + service = described_class.new(project, user) + + expect { service.execute(list) }.not_to change(board.lists, :count) + end + end +end diff --git a/spec/services/boards/lists/generate_service_spec.rb b/spec/services/boards/lists/generate_service_spec.rb new file mode 100644 index 00000000000..8b2f5e81338 --- /dev/null +++ b/spec/services/boards/lists/generate_service_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe Boards::Lists::GenerateService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + + subject(:service) { described_class.new(project, user) } + + context 'when board lists is empty' do + it 'creates the default lists' do + expect { service.execute(board) }.to change(board.lists, :count).by(2) + end + end + + context 'when board lists is not empty' do + it 'does not creates the default lists' do + create(:list, board: board) + + expect { service.execute(board) }.not_to change(board.lists, :count) + end + end + + context 'when project labels does not contains any list label' do + it 'creates labels' do + expect { service.execute(board) }.to change(project.labels, :count).by(2) + end + end + + context 'when project labels contains some of list label' do + it 'creates the missing labels' do + create(:label, project: project, name: 'Doing') + + expect { service.execute(board) }.to change(project.labels, :count).by(1) + end + end + end +end diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb new file mode 100644 index 00000000000..334cee3f06d --- /dev/null +++ b/spec/services/boards/lists/list_service_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe Boards::Lists::ListService, services: true do + describe '#execute' do + it "returns board's lists" do + project = create(:empty_project) + board = create(:board, project: project) + label = create(:label, project: project) + list = create(:list, board: board, label: label) + + service = described_class.new(project, double) + + expect(service.execute(board)).to eq [board.backlog_list, list, board.done_list] + end + end +end diff --git a/spec/services/boards/lists/move_service_spec.rb b/spec/services/boards/lists/move_service_spec.rb new file mode 100644 index 00000000000..63fa0bb8c5f --- /dev/null +++ b/spec/services/boards/lists/move_service_spec.rb @@ -0,0 +1,110 @@ +require 'spec_helper' + +describe Boards::Lists::MoveService, services: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + + let!(:backlog) { create(:backlog_list, board: board) } + let!(:planning) { create(:list, board: board, position: 0) } + let!(:development) { create(:list, board: board, position: 1) } + let!(:review) { create(:list, board: board, position: 2) } + let!(:staging) { create(:list, board: board, position: 3) } + let!(:done) { create(:done_list, board: board) } + + context 'when list type is set to label' do + it 'keeps position of lists when new position is nil' do + service = described_class.new(project, user, position: nil) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is equal to old position' do + service = described_class.new(project, user, position: planning.position) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is negative' do + service = described_class.new(project, user, position: -1) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is equal to number of labels lists' do + service = described_class.new(project, user, position: board.lists.label.size) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when new positon is greater than number of labels lists' do + service = described_class.new(project, user, position: board.lists.label.size + 1) + + service.execute(planning) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'increments position of intermediate lists when new positon is equal to first position' do + service = described_class.new(project, user, position: 0) + + service.execute(staging) + + expect(current_list_positions).to eq [1, 2, 3, 0] + end + + it 'decrements position of intermediate lists when new positon is equal to last position' do + service = described_class.new(project, user, position: board.lists.label.last.position) + + service.execute(planning) + + expect(current_list_positions).to eq [3, 0, 1, 2] + end + + it 'decrements position of intermediate lists when new position is greater than old position' do + service = described_class.new(project, user, position: 2) + + service.execute(planning) + + expect(current_list_positions).to eq [2, 0, 1, 3] + end + + it 'increments position of intermediate lists when new position is lower than old position' do + service = described_class.new(project, user, position: 1) + + service.execute(staging) + + expect(current_list_positions).to eq [0, 2, 3, 1] + end + end + + it 'keeps position of lists when list type is backlog' do + service = described_class.new(project, user, position: 2) + + service.execute(backlog) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + + it 'keeps position of lists when list type is done' do + service = described_class.new(project, user, position: 2) + + service.execute(done) + + expect(current_list_positions).to eq [0, 1, 2, 3] + end + end + + def current_list_positions + [planning, development, review, staging].map { |list| list.reload.position } + end +end diff --git a/spec/services/ci/create_builds_service_spec.rb b/spec/services/ci/create_builds_service_spec.rb deleted file mode 100644 index 8b0becd83d3..00000000000 --- a/spec/services/ci/create_builds_service_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -describe Ci::CreateBuildsService, services: true do - let(:pipeline) { create(:ci_pipeline, ref: 'master') } - let(:user) { create(:user) } - - describe '#execute' do - # Using stubbed .gitlab-ci.yml created in commit factory - # - - subject do - described_class.new(pipeline).execute('test', user, status, nil) - end - - context 'next builds available' do - let(:status) { 'success' } - - it { is_expected.to be_an_instance_of Array } - it { is_expected.to all(be_an_instance_of Ci::Build) } - - it 'does not persist created builds' do - expect(subject.first).not_to be_persisted - end - end - - context 'builds skipped' do - let(:status) { 'skipped' } - - it { is_expected.to be_empty } - end - end -end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb new file mode 100644 index 00000000000..4aadd009f3e --- /dev/null +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -0,0 +1,214 @@ +require 'spec_helper' + +describe Ci::CreatePipelineService, services: true do + let(:project) { FactoryGirl.create(:project) } + let(:user) { create(:admin) } + + before do + stub_ci_pipeline_to_return_yaml_file + end + + describe '#execute' do + def execute(params) + described_class.new(project, user, params).execute + end + + context 'valid params' do + let(:pipeline) do + execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: [{ message: "Message" }]) + end + + it { expect(pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(pipeline).to be_valid } + it { expect(pipeline).to be_persisted } + it { expect(pipeline).to eq(project.pipelines.last) } + it { expect(pipeline).to have_attributes(user: user) } + it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) } + end + + context "skip tag if there is no build for it" do + it "creates commit if there is appropriate job" do + result = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: [{ message: "Message" }]) + expect(result).to be_persisted + end + + it "creates commit if there is no appropriate job but deploy job has right ref setting" do + config = YAML.dump({ deploy: { script: "ls", only: ["master"] } }) + stub_ci_pipeline_yaml_file(config) + result = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: [{ message: "Message" }]) + + expect(result).to be_persisted + end + end + + it 'skips creating pipeline for refs without .gitlab-ci.yml' do + stub_ci_pipeline_yaml_file(nil) + result = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: [{ message: 'Message' }]) + + expect(result).not_to be_persisted + expect(Ci::Pipeline.count).to eq(0) + end + + it 'fails commits if yaml is invalid' do + message = 'message' + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } + stub_ci_pipeline_yaml_file('invalid: file: file') + commits = [{ message: message }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq('failed') + expect(pipeline.yaml_errors).not_to be_nil + end + + context 'when commit contains a [ci skip] directive' do + let(:message) { "some message[ci skip]" } + let(:messageFlip) { "some message[skip ci]" } + let(:capMessage) { "some message[CI SKIP]" } + let(:capMessageFlip) { "some message[SKIP CI]" } + + before do + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } + end + + it "skips builds creation if there is [ci skip] tag in commit message" do + commits = [{ message: message }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq("skipped") + end + + it "skips builds creation if there is [skip ci] tag in commit message" do + commits = [{ message: messageFlip }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq("skipped") + end + + it "skips builds creation if there is [CI SKIP] tag in commit message" do + commits = [{ message: capMessage }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq("skipped") + end + + it "skips builds creation if there is [SKIP CI] tag in commit message" do + commits = [{ message: capMessageFlip }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq("skipped") + end + + it "does not skips builds creation if there is no [ci skip] or [skip ci] tag in commit message" do + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { "some message" } + + commits = [{ message: "some message" }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.first.name).to eq("rspec") + end + + it "fails builds creation if there is [ci skip] tag in commit message and yaml is invalid" do + stub_ci_pipeline_yaml_file('invalid: file: fiile') + commits = [{ message: message }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq("failed") + expect(pipeline.yaml_errors).not_to be_nil + end + end + + it "creates commit with failed status if yaml is invalid" do + stub_ci_pipeline_yaml_file('invalid: file') + commits = [{ message: "some message" }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) + + expect(pipeline).to be_persisted + expect(pipeline.status).to eq("failed") + expect(pipeline.builds.any?).to be false + end + + context 'when there are no jobs for this pipeline' do + before do + config = YAML.dump({ test: { script: 'ls', only: ['feature'] } }) + stub_ci_pipeline_yaml_file(config) + end + + it 'does not create a new pipeline' do + result = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: [{ message: 'some msg' }]) + + expect(result).not_to be_persisted + expect(Ci::Build.all).to be_empty + expect(Ci::Pipeline.count).to eq(0) + end + end + + context 'with manual actions' do + before do + config = YAML.dump({ deploy: { script: 'ls', when: 'manual' } }) + stub_ci_pipeline_yaml_file(config) + end + + it 'does not create a new pipeline' do + result = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: [{ message: 'some msg' }]) + + expect(result).to be_persisted + expect(result.manual_actions).not_to be_empty + end + end + end +end diff --git a/spec/services/ci/create_trigger_request_service_spec.rb b/spec/services/ci/create_trigger_request_service_spec.rb index b72e0bd3dbe..d8c443d29d5 100644 --- a/spec/services/ci/create_trigger_request_service_spec.rb +++ b/spec/services/ci/create_trigger_request_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Ci::CreateTriggerRequestService, services: true do - let(:service) { Ci::CreateTriggerRequestService.new } + let(:service) { described_class.new } let(:project) { create(:project) } let(:trigger) { create(:ci_trigger, project: project) } @@ -27,8 +27,7 @@ describe Ci::CreateTriggerRequestService, services: true do subject { service.execute(project, trigger, 'master') } before do - stub_ci_pipeline_yaml_file('{}') - FactoryGirl.create :ci_pipeline, project: project + stub_ci_pipeline_yaml_file('script: { only: [develop], script: hello World }') end it { expect(subject).to be_nil } diff --git a/spec/services/ci/image_for_build_service_spec.rb b/spec/services/ci/image_for_build_service_spec.rb index 3a3e3efe709..b3e0a7b9b58 100644 --- a/spec/services/ci/image_for_build_service_spec.rb +++ b/spec/services/ci/image_for_build_service_spec.rb @@ -5,8 +5,8 @@ module Ci let(:service) { ImageForBuildService.new } let(:project) { FactoryGirl.create(:empty_project) } let(:commit_sha) { '01234567890123456789' } - let(:commit) { project.ensure_pipeline(commit_sha, 'master') } - let(:build) { FactoryGirl.create(:ci_build, pipeline: commit) } + let(:pipeline) { project.ensure_pipeline('master', commit_sha) } + let(:build) { FactoryGirl.create(:ci_build, pipeline: pipeline) } describe '#execute' do before { build } diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb new file mode 100644 index 00000000000..ff113efd916 --- /dev/null +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -0,0 +1,391 @@ +require 'spec_helper' + +describe Ci::ProcessPipelineService, services: true do + let(:pipeline) { create(:ci_pipeline, ref: 'master') } + let(:user) { create(:user) } + let(:config) { nil } + + before do + allow(pipeline).to receive(:ci_yaml_file).and_return(config) + end + + describe '#execute' do + def all_builds + pipeline.builds + end + + def builds + all_builds.where.not(status: [:created, :skipped]) + end + + def process_pipeline + described_class.new(pipeline.project, user).execute(pipeline) + end + + def succeed_pending + builds.pending.update_all(status: 'success') + end + + context 'start queuing next builds' do + before do + create(:ci_build, :created, pipeline: pipeline, name: 'linux', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'mac', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'rspec', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'rubocop', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'deploy', stage_idx: 2) + end + + it 'processes a pipeline' do + expect(process_pipeline).to be_truthy + succeed_pending + expect(builds.success.count).to eq(2) + + expect(process_pipeline).to be_truthy + succeed_pending + expect(builds.success.count).to eq(4) + + expect(process_pipeline).to be_truthy + succeed_pending + expect(builds.success.count).to eq(5) + + expect(process_pipeline).to be_falsey + end + + it 'does not process pipeline if existing stage is running' do + expect(process_pipeline).to be_truthy + expect(builds.pending.count).to eq(2) + + expect(process_pipeline).to be_falsey + expect(builds.pending.count).to eq(2) + end + end + + context 'custom stage with first job allowed to fail' do + before do + create(:ci_build, :created, pipeline: pipeline, name: 'clean_job', stage_idx: 0, allow_failure: true) + create(:ci_build, :created, pipeline: pipeline, name: 'test_job', stage_idx: 1, allow_failure: true) + end + + it 'automatically triggers a next stage when build finishes' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:status)).to contain_exactly('pending') + + pipeline.builds.running_or_pending.each(&:drop) + expect(builds.pluck(:status)).to contain_exactly('failed', 'pending') + end + end + + context 'properly creates builds when "when" is defined' do + before do + create(:ci_build, :created, pipeline: pipeline, name: 'build', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'test', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'test_failure', stage_idx: 2, when: 'on_failure') + create(:ci_build, :created, pipeline: pipeline, name: 'deploy', stage_idx: 3) + create(:ci_build, :created, pipeline: pipeline, name: 'production', stage_idx: 3, when: 'manual') + create(:ci_build, :created, pipeline: pipeline, name: 'cleanup', stage_idx: 4, when: 'always') + create(:ci_build, :created, pipeline: pipeline, name: 'clear cache', stage_idx: 4, when: 'manual') + end + + context 'when builds are successful' do + it 'properly creates builds' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build') + expect(builds.pluck(:status)).to contain_exactly('pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test') + expect(builds.pluck(:status)).to contain_exactly('success', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') + expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') + expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'success') + pipeline.reload + expect(pipeline.status).to eq('success') + end + end + + context 'when test job fails' do + it 'properly creates builds' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build') + expect(builds.pluck(:status)).to contain_exactly('pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test') + expect(builds.pluck(:status)).to contain_exactly('success', 'pending') + pipeline.builds.running_or_pending.each(&:drop) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') + expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') + expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'success') + pipeline.reload + expect(pipeline.status).to eq('failed') + end + end + + context 'when test and test_failure jobs fail' do + it 'properly creates builds' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build') + expect(builds.pluck(:status)).to contain_exactly('pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test') + expect(builds.pluck(:status)).to contain_exactly('success', 'pending') + pipeline.builds.running_or_pending.each(&:drop) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') + expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') + pipeline.builds.running_or_pending.each(&:drop) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') + expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') + expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'success') + pipeline.reload + expect(pipeline.status).to eq('failed') + end + end + + context 'when deploy job fails' do + it 'properly creates builds' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build') + expect(builds.pluck(:status)).to contain_exactly('pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test') + expect(builds.pluck(:status)).to contain_exactly('success', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') + expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') + pipeline.builds.running_or_pending.each(&:drop) + + expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') + expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'success') + pipeline.reload + expect(pipeline.status).to eq('failed') + end + end + + context 'when build is canceled in the second stage' do + it 'does not schedule builds after build has been canceled' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build') + expect(builds.pluck(:status)).to contain_exactly('pending') + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.running_or_pending).not_to be_empty + + expect(builds.pluck(:name)).to contain_exactly('build', 'test') + expect(builds.pluck(:status)).to contain_exactly('success', 'pending') + pipeline.builds.running_or_pending.each(&:cancel) + + expect(builds.running_or_pending).to be_empty + expect(pipeline.reload.status).to eq('canceled') + end + end + + context 'when listing manual actions' do + it 'returns only for skipped builds' do + # currently all builds are created + expect(process_pipeline).to be_truthy + expect(manual_actions).to be_empty + + # succeed stage build + pipeline.builds.running_or_pending.each(&:success) + expect(manual_actions).to be_empty + + # succeed stage test + pipeline.builds.running_or_pending.each(&:success) + expect(manual_actions).to be_one # production + + # succeed stage deploy + pipeline.builds.running_or_pending.each(&:success) + expect(manual_actions).to be_many # production and clear cache + end + + def manual_actions + pipeline.manual_actions + end + end + end + + context 'when there are manual/on_failure jobs in earlier stages' do + before do + builds + process_pipeline + builds.each(&:reload) + end + + context 'when first stage has only manual jobs' do + let(:builds) do + [create_build('build', 0, 'manual'), + create_build('check', 1), + create_build('test', 2)] + end + + it 'starts from the second stage' do + expect(builds.map(&:status)).to eq(%w[skipped pending created]) + end + end + + context 'when second stage has only manual jobs' do + let(:builds) do + [create_build('check', 0), + create_build('build', 1, 'manual'), + create_build('test', 2)] + end + + it 'skips second stage and continues on third stage' do + expect(builds.map(&:status)).to eq(%w[pending created created]) + + builds.first.success + builds.each(&:reload) + + expect(builds.map(&:status)).to eq(%w[success skipped pending]) + end + end + + context 'when second stage has only on_failure jobs' do + let(:builds) do + [create_build('check', 0), + create_build('build', 1, 'on_failure'), + create_build('test', 2)] + end + + it 'skips second stage and continues on third stage' do + expect(builds.map(&:status)).to eq(%w[pending created created]) + + builds.first.success + builds.each(&:reload) + + expect(builds.map(&:status)).to eq(%w[success skipped pending]) + end + end + + def create_build(name, stage_idx, when_value = nil) + create(:ci_build, + :created, + pipeline: pipeline, + name: name, + stage_idx: stage_idx, + when: when_value) + end + end + + context 'when failed build in the middle stage is retried' do + context 'when failed build is the only unsuccessful build in the stage' do + before do + create(:ci_build, :created, pipeline: pipeline, name: 'build:1', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'build:2', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'test:1', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'test:2', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'deploy:1', stage_idx: 2) + create(:ci_build, :created, pipeline: pipeline, name: 'deploy:2', stage_idx: 2) + end + + it 'does trigger builds in the next stage' do + expect(process_pipeline).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build:1', 'build:2') + + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)) + .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2') + + pipeline.builds.find_by(name: 'test:1').success + pipeline.builds.find_by(name: 'test:2').drop + + expect(builds.pluck(:name)) + .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2') + + Ci::Build.retry(pipeline.builds.find_by(name: 'test:2')).success + + expect(builds.pluck(:name)).to contain_exactly( + 'build:1', 'build:2', 'test:1', 'test:2', 'test:2', 'deploy:1', 'deploy:2') + end + end + end + + context 'creates a builds from .gitlab-ci.yml' do + let(:config) do + YAML.dump({ + rspec: { + stage: 'test', + script: 'rspec' + }, + rubocop: { + stage: 'test', + script: 'rubocop' + }, + deploy: { + stage: 'deploy', + script: 'deploy' + } + }) + end + + # Using stubbed .gitlab-ci.yml created in commit factory + # + + before do + stub_ci_pipeline_yaml_file(config) + create(:ci_build, :created, pipeline: pipeline, name: 'linux', stage: 'build', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'mac', stage: 'build', stage_idx: 0) + end + + it 'when processing a pipeline' do + # Currently we have two builds with state created + expect(builds.count).to eq(0) + expect(all_builds.count).to eq(2) + + # Create builds will mark the created as pending + expect(process_pipeline).to be_truthy + expect(builds.count).to eq(2) + expect(all_builds.count).to eq(2) + + # When we builds succeed we will create a rest of pipeline from .gitlab-ci.yml + # We will have 2 succeeded, 2 pending (from stage test), total 5 (one more build from deploy) + succeed_pending + expect(process_pipeline).to be_truthy + expect(builds.success.count).to eq(2) + expect(builds.pending.count).to eq(2) + expect(all_builds.count).to eq(5) + + # When we succeed the 2 pending from stage test, + # We will queue a deploy stage, no new builds will be created + succeed_pending + expect(process_pipeline).to be_truthy + expect(builds.pending.count).to eq(1) + expect(builds.success.count).to eq(4) + expect(all_builds.count).to eq(5) + + # When we succeed last pending build, we will have a total of 5 succeeded builds, no new builds will be created + succeed_pending + expect(process_pipeline).to be_falsey + expect(builds.success.count).to eq(5) + expect(all_builds.count).to eq(5) + end + end + end +end diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_build_service_spec.rb index 026d0ca6534..1e21a32a062 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_build_service_spec.rb @@ -151,6 +151,25 @@ module Ci it { expect(build.runner).to eq(specific_runner) } end end + + context 'disallow when builds are disabled' do + before do + project.update(shared_runners_enabled: true) + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + end + + context 'and uses shared runner' do + let(:build) { service.execute(shared_runner) } + + it { expect(build).to be_nil } + end + + context 'and uses specific runner' do + let(:build) { service.execute(specific_runner) } + + it { expect(build).to be_nil } + end + end end end end diff --git a/spec/services/compare_service_spec.rb b/spec/services/compare_service_spec.rb new file mode 100644 index 00000000000..3760f19aaa2 --- /dev/null +++ b/spec/services/compare_service_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe CompareService, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:service) { described_class.new } + + describe '#execute' do + context 'compare with base, like feature...fix' do + subject { service.execute(project, 'feature', project, 'fix', straight: false) } + + it { expect(subject.diffs.size).to eq(1) } + end + + context 'straight compare, like feature..fix' do + subject { service.execute(project, 'feature', project, 'fix', straight: true) } + + it { expect(subject.diffs.size).to eq(3) } + end + end +end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb deleted file mode 100644 index d4c5e584421..00000000000 --- a/spec/services/create_commit_builds_service_spec.rb +++ /dev/null @@ -1,241 +0,0 @@ -require 'spec_helper' - -describe CreateCommitBuildsService, services: true do - let(:service) { CreateCommitBuildsService.new } - let(:project) { FactoryGirl.create(:empty_project) } - let(:user) { create(:user) } - - before do - stub_ci_pipeline_to_return_yaml_file - end - - describe '#execute' do - context 'valid params' do - let(:pipeline) do - service.execute(project, user, - ref: 'refs/heads/master', - before: '00000000', - after: '31das312', - commits: [{ message: "Message" }] - ) - end - - it { expect(pipeline).to be_kind_of(Ci::Pipeline) } - it { expect(pipeline).to be_valid } - it { expect(pipeline).to be_persisted } - it { expect(pipeline).to eq(project.pipelines.last) } - it { expect(pipeline).to have_attributes(user: user) } - it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) } - end - - context "skip tag if there is no build for it" do - it "creates commit if there is appropriate job" do - result = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: [{ message: "Message" }] - ) - expect(result).to be_persisted - end - - it "creates commit if there is no appropriate job but deploy job has right ref setting" do - config = YAML.dump({ deploy: { script: "ls", only: ["0_1"] } }) - stub_ci_pipeline_yaml_file(config) - - result = service.execute(project, user, - ref: 'refs/heads/0_1', - before: '00000000', - after: '31das312', - commits: [{ message: "Message" }] - ) - expect(result).to be_persisted - end - end - - it 'skips creating pipeline for refs without .gitlab-ci.yml' do - stub_ci_pipeline_yaml_file(nil) - result = service.execute(project, user, - ref: 'refs/heads/0_1', - before: '00000000', - after: '31das312', - commits: [{ message: 'Message' }] - ) - expect(result).to be_falsey - expect(Ci::Pipeline.count).to eq(0) - end - - it 'fails commits if yaml is invalid' do - message = 'message' - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } - stub_ci_pipeline_yaml_file('invalid: file: file') - commits = [{ message: message }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq('failed') - expect(pipeline.yaml_errors).not_to be_nil - end - - context 'when commit contains a [ci skip] directive' do - let(:message) { "some message[ci skip]" } - let(:messageFlip) { "some message[skip ci]" } - let(:capMessage) { "some message[CI SKIP]" } - let(:capMessageFlip) { "some message[SKIP CI]" } - - before do - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } - end - - it "skips builds creation if there is [ci skip] tag in commit message" do - commits = [{ message: message }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - end - - it "skips builds creation if there is [skip ci] tag in commit message" do - commits = [{ message: messageFlip }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - end - - it "skips builds creation if there is [CI SKIP] tag in commit message" do - commits = [{ message: capMessage }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - end - - it "skips builds creation if there is [SKIP CI] tag in commit message" do - commits = [{ message: capMessageFlip }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - end - - it "does not skips builds creation if there is no [ci skip] or [skip ci] tag in commit message" do - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { "some message" } - - commits = [{ message: "some message" }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - - expect(pipeline).to be_persisted - expect(pipeline.builds.first.name).to eq("staging") - end - - it "skips builds creation if there is [ci skip] tag in commit message and yaml is invalid" do - stub_ci_pipeline_yaml_file('invalid: file: fiile') - commits = [{ message: message }] - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - expect(pipeline.yaml_errors).to be_nil - end - end - - it "skips build creation if there are already builds" do - allow_any_instance_of(Ci::Pipeline).to receive(:ci_yaml_file) { gitlab_ci_yaml } - - commits = [{ message: "message" }] - pipeline = service.execute(project, user, - ref: 'refs/heads/master', - before: '00000000', - after: '31das312', - commits: commits - ) - expect(pipeline).to be_persisted - expect(pipeline.builds.count(:all)).to eq(2) - - pipeline = service.execute(project, user, - ref: 'refs/heads/master', - before: '00000000', - after: '31das312', - commits: commits - ) - expect(pipeline).to be_persisted - expect(pipeline.builds.count(:all)).to eq(2) - end - - it "creates commit with failed status if yaml is invalid" do - stub_ci_pipeline_yaml_file('invalid: file') - - commits = [{ message: "some message" }] - - pipeline = service.execute(project, user, - ref: 'refs/tags/0_1', - before: '00000000', - after: '31das312', - commits: commits - ) - - expect(pipeline).to be_persisted - expect(pipeline.status).to eq("failed") - expect(pipeline.builds.any?).to be false - end - - context 'when there are no jobs for this pipeline' do - before do - config = YAML.dump({ test: { script: 'ls', only: ['feature'] } }) - stub_ci_pipeline_yaml_file(config) - end - - it 'does not create a new pipeline' do - result = service.execute(project, user, - ref: 'refs/heads/master', - before: '00000000', - after: '31das312', - commits: [{ message: 'some msg' }]) - - expect(result).to be_falsey - expect(Ci::Build.all).to be_empty - expect(Ci::Pipeline.count).to eq(0) - end - end - end -end diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index 8da2a2b3c1b..343b4385bf2 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -41,7 +41,7 @@ describe CreateDeploymentService, services: true do context 'for environment with invalid name' do let(:params) do - { environment: 'name with spaces', + { environment: 'name,with,commas', ref: 'master', tag: false, sha: '97de212e80737a608d939f648d959671fb0a0142', @@ -56,8 +56,36 @@ describe CreateDeploymentService, services: true do expect(subject).not_to be_persisted end end + + context 'when variables are used' do + let(:params) do + { environment: 'review-apps/$CI_BUILD_REF_NAME', + ref: 'master', + tag: false, + sha: '97de212e80737a608d939f648d959671fb0a0142', + options: { + name: 'review-apps/$CI_BUILD_REF_NAME', + url: 'http://$CI_BUILD_REF_NAME.review-apps.gitlab.com' + }, + variables: [ + { key: 'CI_BUILD_REF_NAME', value: 'feature-review-apps' } + ] + } + end + + it 'does create a new environment' do + expect { subject }.to change { Environment.count }.by(1) + + expect(subject.environment.name).to eq('review-apps/feature-review-apps') + expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com') + end + + it 'does create a new deployment' do + expect(subject).to be_persisted + end + end end - + describe 'processing of builds' do let(:environment) { nil } @@ -95,6 +123,12 @@ describe CreateDeploymentService, services: true do expect(Deployment.last.deployable).to eq(deployable) end + + it 'create environment has URL set' do + subject + + expect(Deployment.last.environment.external_url).not_to be_nil + end end context 'without environment specified' do @@ -107,7 +141,10 @@ describe CreateDeploymentService, services: true do context 'when environment is specified' do let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production') } + let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production', options: options) } + let(:options) do + { environment: { name: 'production', url: 'http://gitlab.com' } } + end context 'when build succeeds' do it_behaves_like 'does create environment and deployment' do @@ -132,4 +169,83 @@ describe CreateDeploymentService, services: true do end end end + + describe "merge request metrics" do + let(:params) do + { + environment: 'production', + ref: 'master', + tag: false, + sha: '97de212e80737a608d939f648d959671fb0a0142b', + } + end + + let(:merge_request) { create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: project) } + + context "while updating the 'first_deployed_to_production_at' time" do + before { merge_request.mark_as_merged } + + context "for merge requests merged before the current deploy" do + it "sets the time if the deploy's environment is 'production'" do + time = Time.now + Timecop.freeze(time) { service.execute } + + expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_within(1.second).of(time) + end + + it "doesn't set the time if the deploy's environment is not 'production'" do + staging_params = params.merge(environment: 'staging') + service = described_class.new(project, user, staging_params) + service.execute + + expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil + end + + it 'does not raise errors if the merge request does not have a metrics record' do + merge_request.metrics.destroy + + expect(merge_request.reload.metrics).to be_nil + expect { service.execute }.not_to raise_error + end + end + + context "for merge requests merged before the previous deploy" do + context "if the 'first_deployed_to_production_at' time is already set" do + it "does not overwrite the older 'first_deployed_to_production_at' time" do + # Previous deploy + time = Time.now + Timecop.freeze(time) { service.execute } + + expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_within(1.second).of(time) + + # Current deploy + service = described_class.new(project, user, params) + Timecop.freeze(time + 12.hours) { service.execute } + + expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_within(1.second).of(time) + end + end + + context "if the 'first_deployed_to_production_at' time is not already set" do + it "does not overwrite the older 'first_deployed_to_production_at' time" do + # Previous deploy + time = 5.minutes.from_now + Timecop.freeze(time) { service.execute } + + expect(merge_request.reload.metrics.merged_at).to be < merge_request.reload.metrics.first_deployed_to_production_at + + merge_request.reload.metrics.update(first_deployed_to_production_at: nil) + + expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil + + # Current deploy + service = described_class.new(project, user, params) + Timecop.freeze(time + 12.hours) { service.execute } + + expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil + end + end + end + end + end end diff --git a/spec/services/create_snippet_service_spec.rb b/spec/services/create_snippet_service_spec.rb index 7a850066bf8..d81d0fd76c9 100644 --- a/spec/services/create_snippet_service_spec.rb +++ b/spec/services/create_snippet_service_spec.rb @@ -19,7 +19,7 @@ describe CreateSnippetService, services: true do @opts.merge!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) end - it 'non-admins should not be able to create a public snippet' do + it 'non-admins are not able to create a public snippet' do snippet = create_snippet(nil, @user, @opts) expect(snippet.errors.messages).to have_key(:visibility_level) expect(snippet.errors.messages[:visibility_level].first).to( @@ -27,7 +27,7 @@ describe CreateSnippetService, services: true do ) end - it 'admins should be able to create a public snippet' do + it 'admins are able to create a public snippet' do snippet = create_snippet(nil, @admin, @opts) expect(snippet.errors.any?).to be_falsey expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) diff --git a/spec/services/delete_user_service_spec.rb b/spec/services/delete_user_service_spec.rb index a65938fa03b..418a12a83a9 100644 --- a/spec/services/delete_user_service_spec.rb +++ b/spec/services/delete_user_service_spec.rb @@ -9,13 +9,15 @@ describe DeleteUserService, services: true do context 'no options are given' do it 'deletes the user' do - DeleteUserService.new(current_user).execute(user) + user_data = DeleteUserService.new(current_user).execute(user) - expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + expect { user_data['email'].to eq(user.email) } + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + expect { Namespace.with_deleted.find(user.namespace.id) }.to raise_error(ActiveRecord::RecordNotFound) end it 'will delete the project in the near future' do - expect_any_instance_of(Projects::DestroyService).to receive(:pending_delete!).once + expect_any_instance_of(Projects::DestroyService).to receive(:async_execute).once DeleteUserService.new(current_user).execute(user) end diff --git a/spec/services/destroy_group_service_spec.rb b/spec/services/destroy_group_service_spec.rb index eca8ddd8ea4..da724643604 100644 --- a/spec/services/destroy_group_service_spec.rb +++ b/spec/services/destroy_group_service_spec.rb @@ -7,38 +7,52 @@ describe DestroyGroupService, services: true do let!(:gitlab_shell) { Gitlab::Shell.new } let!(:remove_path) { group.path + "+#{group.id}+deleted" } - context 'database records' do - before do - destroy_group(group, user) + shared_examples 'group destruction' do |async| + context 'database records' do + before do + destroy_group(group, user, async) + end + + it { expect(Group.all).not_to include(group) } + it { expect(Project.all).not_to include(project) } end - it { expect(Group.all).not_to include(group) } - it { expect(Project.all).not_to include(project) } - end + context 'file system' do + context 'Sidekiq inline' do + before do + # Run sidekiq immediatly to check that renamed dir will be removed + Sidekiq::Testing.inline! { destroy_group(group, user, async) } + end - context 'file system' do - context 'Sidekiq inline' do - before do - # Run sidekiq immediatly to check that renamed dir will be removed - Sidekiq::Testing.inline! { destroy_group(group, user) } + it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey } + it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey } end - it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey } - it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey } - end + context 'Sidekiq fake' do + before do + # Dont run sidekiq to check if renamed repository exists + Sidekiq::Testing.fake! { destroy_group(group, user, async) } + end - context 'Sidekiq fake' do - before do - # Dont run sidekiq to check if renamed repository exists - Sidekiq::Testing.fake! { destroy_group(group, user) } + it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey } + it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_truthy } end + end - it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey } - it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_truthy } + def destroy_group(group, user, async) + if async + DestroyGroupService.new(group, user).async_execute + else + DestroyGroupService.new(group, user).execute + end end end - def destroy_group(group, user) - DestroyGroupService.new(group, user).execute + describe 'asynchronous delete' do + it_behaves_like 'group destruction', true + end + + describe 'synchronous delete' do + it_behaves_like 'group destruction', false end end diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb index 789836f71bb..16a9956fe7f 100644 --- a/spec/services/event_create_service_spec.rb +++ b/spec/services/event_create_service_spec.rb @@ -41,7 +41,7 @@ describe EventCreateService, services: true do it { expect(service.open_mr(merge_request, merge_request.author)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.open_mr(merge_request, merge_request.author) }.to change { Event.count } end end @@ -51,7 +51,7 @@ describe EventCreateService, services: true do it { expect(service.close_mr(merge_request, merge_request.author)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.close_mr(merge_request, merge_request.author) }.to change { Event.count } end end @@ -61,7 +61,7 @@ describe EventCreateService, services: true do it { expect(service.merge_mr(merge_request, merge_request.author)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.merge_mr(merge_request, merge_request.author) }.to change { Event.count } end end @@ -71,7 +71,7 @@ describe EventCreateService, services: true do it { expect(service.reopen_mr(merge_request, merge_request.author)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.reopen_mr(merge_request, merge_request.author) }.to change { Event.count } end end @@ -85,7 +85,7 @@ describe EventCreateService, services: true do it { expect(service.open_milestone(milestone, user)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.open_milestone(milestone, user) }.to change { Event.count } end end @@ -95,7 +95,7 @@ describe EventCreateService, services: true do it { expect(service.close_milestone(milestone, user)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.close_milestone(milestone, user) }.to change { Event.count } end end @@ -105,7 +105,7 @@ describe EventCreateService, services: true do it { expect(service.destroy_milestone(milestone, user)).to be_truthy } - it "should create new event" do + it "creates new event" do expect { service.destroy_milestone(milestone, user) }.to change { Event.count } end end diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb new file mode 100644 index 00000000000..d3c37c7820f --- /dev/null +++ b/spec/services/files/update_service_spec.rb @@ -0,0 +1,84 @@ +require "spec_helper" + +describe Files::UpdateService do + subject { described_class.new(project, user, commit_params) } + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:file_path) { 'files/ruby/popen.rb' } + let(:new_contents) { "New Content" } + let(:commit_params) do + { + file_path: file_path, + commit_message: "Update File", + file_content: new_contents, + file_content_encoding: "text", + last_commit_sha: last_commit_sha, + source_project: project, + source_branch: project.default_branch, + target_branch: project.default_branch, + } + end + + before do + project.team << [user, :master] + end + + describe "#execute" do + context "when the file's last commit sha does not match the supplied last_commit_sha" do + let(:last_commit_sha) { "foo" } + + it "returns a hash with the correct error message and a :error status " do + expect { subject.execute }. + to raise_error(Files::UpdateService::FileChangedError, + "You are attempting to update a file that has changed since you started editing it.") + end + end + + context "when the file's last commit sha does match the supplied last_commit_sha" do + let(:last_commit_sha) { Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, file_path).sha } + + it "returns a hash with the :success status " do + results = subject.execute + + expect(results[:status]).to match(:success) + end + + it "updates the file with the new contents" do + subject.execute + + results = project.repository.blob_at_branch(project.default_branch, file_path) + + expect(results.data).to eq(new_contents) + end + end + + context "when the last_commit_sha is not supplied" do + let(:commit_params) do + { + file_path: file_path, + commit_message: "Update File", + file_content: new_contents, + file_content_encoding: "text", + source_project: project, + source_branch: project.default_branch, + target_branch: project.default_branch, + } + end + + it "returns a hash with the :success status " do + results = subject.execute + + expect(results[:status]).to match(:success) + end + + it "updates the file with the new contents" do + subject.execute + + results = project.repository.blob_at_branch(project.default_branch, file_path) + + expect(results.data).to eq(new_contents) + end + end + end +end diff --git a/spec/services/git_hooks_service_spec.rb b/spec/services/git_hooks_service_spec.rb index 3fc37a315c0..41b0968b8b4 100644 --- a/spec/services/git_hooks_service_spec.rb +++ b/spec/services/git_hooks_service_spec.rb @@ -17,7 +17,7 @@ describe GitHooksService, services: true do describe '#execute' do context 'when receive hooks were successful' do - it 'should call post-receive hook' do + it 'calls post-receive hook' do hook = double(trigger: [true, nil]) expect(Gitlab::Git::Hook).to receive(:new).exactly(3).times.and_return(hook) @@ -26,7 +26,7 @@ describe GitHooksService, services: true do end context 'when pre-receive hook failed' do - it 'should not call post-receive hook' do + it 'does not call post-receive hook' do expect(service).to receive(:run_hook).with('pre-receive').and_return([false, '']) expect(service).not_to receive(:run_hook).with('post-receive') @@ -37,7 +37,7 @@ describe GitHooksService, services: true do end context 'when update hook failed' do - it 'should not call post-receive hook' do + it 'does not call post-receive hook' do expect(service).to receive(:run_hook).with('pre-receive').and_return([true, nil]) expect(service).to receive(:run_hook).with('update').and_return([false, '']) expect(service).not_to receive(:run_hook).with('post-receive') diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index ffa998dffc3..dd2a9e9903a 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -184,8 +184,8 @@ describe GitPushService, services: true do context "Updates merge requests" do it "when pushing a new branch for the first time" do - expect(project).to receive(:update_merge_requests). - with(@blankrev, 'newrev', 'refs/heads/master', user) + expect(UpdateMergeRequestsWorker).to receive(:perform_async). + with(project.id, user.id, @blankrev, 'newrev', 'refs/heads/master') execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) end end @@ -227,8 +227,8 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) end it "when pushing a branch for the first time with default branch protection disabled" do @@ -249,8 +249,23 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.last.push_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) - expect(project.protected_branches.last.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) + expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + end + + it "when pushing a branch for the first time with an existing branch permission configured" do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + create(:protected_branch, :no_one_can_push, :developers_can_merge, project: project, name: 'master') + expect(project).to receive(:execute_hooks) + expect(project.default_branch).to eq("master") + expect_any_instance_of(ProtectedBranches::CreateService).not_to receive(:execute) + + execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) + + expect(project.protected_branches).not_to be_empty + expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::NO_ACCESS]) + expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) end it "when pushing a branch for the first time with default branch protection set to 'developers can merge'" do @@ -260,8 +275,8 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) end it "when pushing new commits to existing branch" do @@ -324,6 +339,43 @@ describe GitPushService, services: true do end end + describe "issue metrics" do + let(:issue) { create :issue, project: project } + let(:commit_author) { create :user } + let(:commit) { project.commit } + let(:commit_time) { Time.now } + + before do + project.team << [commit_author, :developer] + project.team << [user, :developer] + + allow(commit).to receive_messages( + safe_message: "this commit \n mentions #{issue.to_reference}", + references: [issue], + author_name: commit_author.name, + author_email: commit_author.email, + committed_date: commit_time + ) + + allow(project.repository).to receive(:commits_between).and_return([commit]) + end + + context "while saving the 'first_mentioned_in_commit_at' metric for an issue" do + it 'sets the metric for referenced issues' do + execute_service(project, user, @oldrev, @newrev, @ref) + + expect(issue.reload.metrics.first_mentioned_in_commit_at).to be_within(1.second).of(commit_time) + end + + it 'does not set the metric for non-referenced issues' do + non_referenced_issue = create(:issue, project: project) + execute_service(project, user, @oldrev, @newrev, @ref) + + expect(non_referenced_issue.reload.metrics.first_mentioned_in_commit_at).to be_nil + end + end + end + describe "closing issues from pushed commits containing a closing reference" do let(:issue) { create :issue, project: project } let(:other_issue) { create :issue, project: project } @@ -396,6 +448,8 @@ describe GitPushService, services: true do let(:jira_tracker) { project.create_jira_service if project.jira_service.nil? } before do + # project.create_jira_service doesn't seem to invalidate the cache here + project.has_external_issue_tracker = true jira_service_settings WebMock.stub_request(:post, jira_api_transition_url) @@ -420,7 +474,7 @@ describe GitPushService, services: true do context "mentioning an issue" do let(:message) { "this is some work.\n\nrelated to JIRA-1" } - it "should initiate one api call to jira server to mention the issue" do + it "initiates one api call to jira server to mention the issue" do execute_service(project, user, @oldrev, @newrev, @ref ) expect(WebMock).to have_requested(:post, jira_api_comment_url).with( @@ -432,7 +486,7 @@ describe GitPushService, services: true do context "closing an issue" do let(:message) { "this is some work.\n\ncloses JIRA-1" } - it "should initiate one api call to jira server to close the issue" do + it "initiates one api call to jira server to close the issue" do transition_body = { transition: { id: '2' @@ -445,7 +499,7 @@ describe GitPushService, services: true do ).once end - it "should initiate one api call to jira server to comment on the issue" do + it "initiates one api call to jira server to comment on the issue" do comment_body = { body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb new file mode 100644 index 00000000000..81b1d327696 --- /dev/null +++ b/spec/services/import_export_clean_up_service_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe ImportExportCleanUpService, services: true do + describe '#execute' do + let(:service) { described_class.new } + + let(:tmp_import_export_folder) { 'tmp/project_exports' } + + context 'when the import/export directory does not exist' do + it 'does not remove any archives' do + path = '/invalid/path/' + stub_repository_downloads_path(path) + + expect(File).to receive(:directory?).with(path + tmp_import_export_folder).and_return(false).at_least(:once) + expect(service).not_to receive(:clean_up_export_files) + + service.execute + end + end + + context 'when the import/export directory exists' do + it 'removes old files' do + in_directory_with_files(mtime: 2.days.ago) do |dir, files| + service.execute + + files.each { |file| expect(File.exist?(file)).to eq false } + expect(File.directory?(dir)).to eq false + end + end + + it 'does not remove new files' do + in_directory_with_files(mtime: 2.hours.ago) do |dir, files| + service.execute + + files.each { |file| expect(File.exist?(file)).to eq true } + expect(File.directory?(dir)).to eq true + end + end + end + + def in_directory_with_files(mtime:) + Dir.mktmpdir do |tmpdir| + stub_repository_downloads_path(tmpdir) + dir = File.join(tmpdir, tmp_import_export_folder, 'subfolder') + FileUtils.mkdir_p(dir) + + files = FileUtils.touch(file_list(dir) + [dir], mtime: mtime.to_time) + + yield(dir, files) + end + end + + def stub_repository_downloads_path(path) + new_shared_settings = Settings.shared.merge('path' => path) + allow(Settings).to receive(:shared).and_return(new_shared_settings) + end + + def file_list(dir) + Array.new(5) do |num| + File.join(dir, "random-#{num}.tar.gz") + end + end + end +end diff --git a/spec/services/issues/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 321b54ac39d..6f7ce8ca992 100644 --- a/spec/services/issues/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -1,14 +1,14 @@ require 'spec_helper' -describe Issues::BulkUpdateService, services: true do +describe Issuable::BulkUpdateService, services: true do let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } def bulk_update(issues, extra_params = {}) bulk_update_params = extra_params - .reverse_merge(issues_ids: Array(issues).map(&:id).join(',')) + .reverse_merge(issuable_ids: Array(issues).map(&:id).join(',')) - Issues::BulkUpdateService.new(project, user, bulk_update_params).execute + Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute('issue') end describe 'close issues' do @@ -217,7 +217,7 @@ describe Issues::BulkUpdateService, services: true do let(:labels) { [merge_requests] } let(:remove_labels) { [regression] } - it 'remove the label IDs from all issues passed' do + it 'removes the label IDs from all issues passed' do expect(issues.map(&:reload).map(&:label_ids).flatten).not_to include(regression.id) end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 67a919ba8ee..5dfb33f4b28 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe Issues::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } + let(:guest) { create(:user) } let(:issue) { create(:issue, assignee: user2) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } @@ -10,27 +11,28 @@ describe Issues::CloseService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context "valid params" do before do perform_enqueued_jobs do - @issue = Issues::CloseService.new(project, user, {}).execute(issue) + described_class.new(project, user).execute(issue) end end - it { expect(@issue).to be_valid } - it { expect(@issue).to be_closed } + it { expect(issue).to be_valid } + it { expect(issue).to be_closed } - it 'should send email to user2 about assign of new issue' do + it 'sends email to user2 about assign of new issue' do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(issue.title) end - it 'should create system note about issue reassign' do - note = @issue.notes.last + it 'creates system note about issue reassign' do + note = issue.notes.last expect(note.note).to include "Status changed to closed" end @@ -39,14 +41,46 @@ describe Issues::CloseService, services: true do end end - context "external issue tracker" do + context 'current user is not authorized to close issue' do + before do + perform_enqueued_jobs do + described_class.new(project, guest).execute(issue) + end + end + + it 'does not close the issue' do + expect(issue).to be_open + end + end + + context 'when issue is not confidential' do + it 'executes issue hooks' do + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :issue_hooks) + + described_class.new(project, user).execute(issue) + end + end + + context 'when issue is confidential' do + it 'executes confidential issue hooks' do + issue = create(:issue, :confidential, project: project) + + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :confidential_issue_hooks) + + described_class.new(project, user).execute(issue) + end + end + + context 'external issue tracker' do before do allow(project).to receive(:default_issues_tracker?).and_return(false) - @issue = Issues::CloseService.new(project, user, {}).execute(issue) + described_class.new(project, user).execute(issue) end - it { expect(@issue).to be_valid } - it { expect(@issue).to be_opened } + it { expect(issue).to be_valid } + it { expect(issue).to be_opened } it { expect(todo.reload).to be_pending } end end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 1ee9f3aae4d..1050502fa19 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -20,16 +20,38 @@ describe Issues::CreateService, services: true do let(:opts) do { title: 'Awesome issue', description: 'please fix', - assignee: assignee, + assignee_id: assignee.id, label_ids: labels.map(&:id), - milestone_id: milestone.id } + milestone_id: milestone.id, + due_date: Date.tomorrow } end - it { expect(issue).to be_valid } - it { expect(issue.title).to eq('Awesome issue') } - it { expect(issue.assignee).to eq assignee } - it { expect(issue.labels).to match_array labels } - it { expect(issue.milestone).to eq milestone } + it 'creates the issue with the given params' do + expect(issue).to be_persisted + expect(issue.title).to eq('Awesome issue') + expect(issue.assignee).to eq assignee + expect(issue.labels).to match_array labels + expect(issue.milestone).to eq milestone + expect(issue.due_date).to eq Date.tomorrow + end + + context 'when current user cannot admin issues in the project' do + let(:guest) { create(:user) } + before do + project.team << [guest, :guest] + end + + it 'filters out params that cannot be set without the :admin_issue permission' do + issue = described_class.new(project, guest, opts).execute + + expect(issue).to be_persisted + expect(issue.title).to eq('Awesome issue') + expect(issue.assignee).to be_nil + expect(issue.labels).to be_empty + expect(issue.milestone).to be_nil + expect(issue.due_date).to be_nil + end + end it 'creates a pending todo for new assignee' do attributes = { @@ -72,6 +94,26 @@ describe Issues::CreateService, services: true do expect(issue.milestone).not_to eq milestone end end + + it 'executes issue hooks when issue is not confidential' do + opts = { title: 'Title', description: 'Description', confidential: false } + + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :issue_hooks) + + described_class.new(project, user, opts).execute + end + + it 'executes confidential issue hooks when issue is confidential' do + opts = { title: 'Title', description: 'Description', confidential: true } + + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :confidential_issue_hooks) + + described_class.new(project, user, opts).execute + end end + + it_behaves_like 'new issuable record that supports slash commands' end end diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb new file mode 100644 index 00000000000..93a8270fd16 --- /dev/null +++ b/spec/services/issues/reopen_service_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Issues::ReopenService, services: true do + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, :closed, project: project) } + + describe '#execute' do + context 'when user is not authorized to reopen issue' do + before do + guest = create(:user) + project.team << [guest, :guest] + + perform_enqueued_jobs do + described_class.new(project, guest).execute(issue) + end + end + + it 'does not reopen the issue' do + expect(issue).to be_closed + end + end + + context 'when user is authrized to reopen issue' do + let(:user) { create(:user) } + + before do + project.team << [user, :master] + end + + context 'when issue is not confidential' do + it 'executes issue hooks' do + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :issue_hooks) + + described_class.new(project, user).execute(issue) + end + end + + context 'when issue is confidential' do + it 'executes confidential issue hooks' do + issue = create(:issue, :confidential, :closed, project: project) + + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :confidential_issue_hooks) + + described_class.new(project, user).execute(issue) + end + end + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index dacbcd8fb46..1638a46ed51 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -23,76 +23,123 @@ describe Issues::UpdateService, services: true do describe 'execute' do def find_note(starting_with) - @issue.notes.find do |note| + issue.notes.find do |note| note && note.note.start_with?(starting_with) end end - context "valid params" do - before do - opts = { + def update_issue(opts) + described_class.new(project, user, opts).execute(issue) + end + + context 'valid params' do + let(:opts) do + { title: 'New title', description: 'Also please fix', assignee_id: user2.id, state_event: 'close', label_ids: [label.id], - confidential: true + due_date: Date.tomorrow } + end - perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) - end + it 'updates the issue with the given params' do + update_issue(opts) - @issue.reload + expect(issue).to be_valid + expect(issue.title).to eq 'New title' + expect(issue.description).to eq 'Also please fix' + expect(issue.assignee).to eq user2 + expect(issue).to be_closed + expect(issue.labels).to match_array [label] + expect(issue.due_date).to eq Date.tomorrow end - it { expect(@issue).to be_valid } - it { expect(@issue.title).to eq('New title') } - it { expect(@issue.assignee).to eq(user2) } - it { expect(@issue).to be_closed } - it { expect(@issue.labels.count).to eq(1) } - it { expect(@issue.labels.first.title).to eq(label.name) } - - it 'should send email to user2 about assign of new issue and email to user3 about issue unassignment' do - deliveries = ActionMailer::Base.deliveries - email = deliveries.last - recipients = deliveries.last(2).map(&:to).flatten - expect(recipients).to include(user2.email, user3.email) - expect(email.subject).to include(issue.title) - end + context 'when current user cannot admin issues in the project' do + let(:guest) { create(:user) } + before do + project.team << [guest, :guest] + end - it 'should create system note about issue reassign' do - note = find_note('Reassigned to') + it 'filters out params that cannot be set without the :admin_issue permission' do + described_class.new(project, guest, opts).execute(issue) - expect(note).not_to be_nil - expect(note.note).to include "Reassigned to \@#{user2.username}" + expect(issue).to be_valid + expect(issue.title).to eq 'New title' + expect(issue.description).to eq 'Also please fix' + expect(issue.assignee).to eq user3 + expect(issue.labels).to be_empty + expect(issue.milestone).to be_nil + expect(issue.due_date).to be_nil + end end - it 'should create system note about issue label edit' do - note = find_note('Added ~') + context 'with background jobs processed' do + before do + perform_enqueued_jobs do + update_issue(opts) + end + end - expect(note).not_to be_nil - expect(note.note).to include "Added ~#{label.id} label" - end + it 'sends email to user2 about assign of new issue and email to user3 about issue unassignment' do + deliveries = ActionMailer::Base.deliveries + email = deliveries.last + recipients = deliveries.last(2).map(&:to).flatten + expect(recipients).to include(user2.email, user3.email) + expect(email.subject).to include(issue.title) + end - it 'creates system note about title change' do - note = find_note('Changed title:') + it 'creates system note about issue reassign' do + note = find_note('Reassigned to') - expect(note).not_to be_nil - expect(note.note).to eq 'Changed title: **{-Old-} title** → **{+New+} title**' + expect(note).not_to be_nil + expect(note.note).to include "Reassigned to \@#{user2.username}" + end + + it 'creates system note about issue label edit' do + note = find_note('Added ~') + + expect(note).not_to be_nil + expect(note.note).to include "Added ~#{label.id} label" + end + + it 'creates system note about title change' do + note = find_note('Changed title:') + + expect(note).not_to be_nil + expect(note.note).to eq 'Changed title: **{-Old-} title** → **{+New+} title**' + end + end + end + + context 'when issue turns confidential' do + let(:opts) do + { + title: 'New title', + description: 'Also please fix', + assignee_id: user2.id, + state_event: 'close', + label_ids: [label.id], + confidential: true + } end it 'creates system note about confidentiality change' do + update_issue(confidential: true) + note = find_note('Made the issue confidential') expect(note).not_to be_nil expect(note.note).to eq 'Made the issue confidential' end - end - def update_issue(opts) - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) - @issue.reload + it 'executes confidential issue hooks' do + expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :confidential_issue_hooks) + expect(project).to receive(:execute_services).with(an_instance_of(Hash), :confidential_issue_hooks) + + update_issue(confidential: true) + end end context 'todos' do @@ -100,7 +147,7 @@ describe Issues::UpdateService, services: true do context 'when the title change' do before do - update_issue({ title: 'New title' }) + update_issue(title: 'New title') end it 'marks pending todos as done' do @@ -110,7 +157,7 @@ describe Issues::UpdateService, services: true do context 'when the description change' do before do - update_issue({ description: 'Also please fix' }) + update_issue(description: 'Also please fix') end it 'marks todos as done' do @@ -120,7 +167,7 @@ describe Issues::UpdateService, services: true do context 'when is reassigned' do before do - update_issue({ assignee: user2 }) + update_issue(assignee: user2) end it 'marks previous assignee todos as done' do @@ -144,7 +191,7 @@ describe Issues::UpdateService, services: true do context 'when the milestone change' do before do - update_issue({ milestone: create(:milestone) }) + update_issue(milestone: create(:milestone)) end it 'marks todos as done' do @@ -154,7 +201,7 @@ describe Issues::UpdateService, services: true do context 'when the labels change' do before do - update_issue({ label_ids: [label.id] }) + update_issue(label_ids: [label.id]) end it 'marks todos as done' do @@ -165,6 +212,7 @@ describe Issues::UpdateService, services: true do context 'when the issue is relabeled' do let!(:non_subscriber) { create(:user) } + let!(:subscriber) do create(:user).tap do |u| label.toggle_subscription(u) @@ -176,7 +224,7 @@ describe Issues::UpdateService, services: true do opts = { label_ids: [label.id] } perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + @issue = described_class.new(project, user, opts).execute(issue) end should_email(subscriber) @@ -190,7 +238,7 @@ describe Issues::UpdateService, services: true do opts = { label_ids: [label.id, label2.id] } perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + @issue = described_class.new(project, user, opts).execute(issue) end should_not_email(subscriber) @@ -201,7 +249,7 @@ describe Issues::UpdateService, services: true do opts = { label_ids: [label2.id] } perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + @issue = described_class.new(project, user, opts).execute(issue) end should_not_email(subscriber) @@ -210,13 +258,15 @@ describe Issues::UpdateService, services: true do end end - context 'when Issue has tasks' do - before { update_issue({ description: "- [ ] Task 1\n- [ ] Task 2" }) } + context 'when issue has tasks' do + before do + update_issue(description: "- [ ] Task 1\n- [ ] Task 2") + end - it { expect(@issue.tasks?).to eq(true) } + it { expect(issue.tasks?).to eq(true) } context 'when tasks are marked as completed' do - before { update_issue({ description: "- [x] Task 1\n- [X] Task 2" }) } + before { update_issue(description: "- [x] Task 1\n- [X] Task 2") } it 'creates system note about task status change' do note1 = find_note('Marked the task **Task 1** as completed') @@ -229,8 +279,8 @@ describe Issues::UpdateService, services: true do context 'when tasks are marked as incomplete' do before do - update_issue({ description: "- [x] Task 1\n- [X] Task 2" }) - update_issue({ description: "- [ ] Task 1\n- [ ] Task 2" }) + update_issue(description: "- [x] Task 1\n- [X] Task 2") + update_issue(description: "- [ ] Task 1\n- [ ] Task 2") end it 'creates system note about task status change' do @@ -244,8 +294,8 @@ describe Issues::UpdateService, services: true do context 'when tasks position has been modified' do before do - update_issue({ description: "- [x] Task 1\n- [X] Task 2" }) - update_issue({ description: "- [x] Task 1\n- [ ] Task 3\n- [ ] Task 2" }) + update_issue(description: "- [x] Task 1\n- [X] Task 2") + update_issue(description: "- [x] Task 1\n- [ ] Task 3\n- [ ] Task 2") end it 'does not create a system note' do @@ -257,8 +307,8 @@ describe Issues::UpdateService, services: true do context 'when a Task list with a completed item is totally replaced' do before do - update_issue({ description: "- [ ] Task 1\n- [X] Task 2" }) - update_issue({ description: "- [ ] One\n- [ ] Two\n- [ ] Three" }) + update_issue(description: "- [ ] Task 1\n- [X] Task 2") + update_issue(description: "- [ ] One\n- [ ] Two\n- [ ] Three") end it 'does not create a system note referencing the position the old item' do @@ -267,9 +317,9 @@ describe Issues::UpdateService, services: true do expect(note).to be_nil end - it 'should not generate a new note at all' do + it 'does not generate a new note at all' do expect do - update_issue({ description: "- [ ] One\n- [ ] Two\n- [ ] Three" }) + update_issue(description: "- [ ] One\n- [ ] Two\n- [ ] Three") end.not_to change { Note.count } end end @@ -277,7 +327,7 @@ describe Issues::UpdateService, services: true do context 'updating labels' do let(:label3) { create(:label, project: project) } - let(:result) { Issues::UpdateService.new(project, user, params).execute(issue).reload } + let(:result) { described_class.new(project, user, params).execute(issue).reload } context 'when add_label_ids and label_ids are passed' do let(:params) { { label_ids: [label.id], add_label_ids: [label3.id] } } @@ -319,5 +369,10 @@ describe Issues::UpdateService, services: true do end end end + + context 'updating mentions' do + let(:mentionable) { issue } + include_examples 'updating mentions', Issues::UpdateService + end end end diff --git a/spec/services/members/approve_access_request_service_spec.rb b/spec/services/members/approve_access_request_service_spec.rb new file mode 100644 index 00000000000..03e296259f9 --- /dev/null +++ b/spec/services/members/approve_access_request_service_spec.rb @@ -0,0 +1,96 @@ +require 'spec_helper' + +describe Members::ApproveAccessRequestService, services: true do + let(:user) { create(:user) } + let(:access_requester) { create(:user) } + let(:project) { create(:project, :public) } + let(:group) { create(:group, :public) } + + shared_examples 'a service raising ActiveRecord::RecordNotFound' do + it 'raises ActiveRecord::RecordNotFound' do + expect { described_class.new(source, user, params).execute }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do + it 'raises Gitlab::Access::AccessDeniedError' do + expect { described_class.new(source, user, params).execute }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + shared_examples 'a service approving an access request' do + it 'succeeds' do + expect { described_class.new(source, user, params).execute }.to change { source.requesters.count }.by(-1) + end + + it 'returns a <Source>Member' do + member = described_class.new(source, user, params).execute + + expect(member).to be_a "#{source.class}Member".constantize + expect(member.requested_at).to be_nil + end + + context 'with a custom access level' do + let(:params) { { user_id: access_requester.id, access_level: Gitlab::Access::MASTER } } + + it 'returns a ProjectMember with the custom access level' do + member = described_class.new(source, user, params).execute + + expect(member.access_level).to eq Gitlab::Access::MASTER + end + end + end + + context 'when no access requester are found' do + let(:params) { { user_id: 42 } } + + it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do + let(:source) { project } + end + + it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do + let(:source) { group } + end + end + + context 'when an access requester is found' do + before do + project.request_access(access_requester) + group.request_access(access_requester) + end + let(:params) { { user_id: access_requester.id } } + + context 'when current user cannot approve access request to the project' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { project } + end + + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { group } + end + end + + context 'when current user can approve access request to the project' do + before do + project.team << [user, :master] + group.add_owner(user) + end + + it_behaves_like 'a service approving an access request' do + let(:source) { project } + end + + it_behaves_like 'a service approving an access request' do + let(:source) { group } + end + + context 'when given a :id' do + let(:params) { { id: project.requesters.find_by!(user_id: access_requester.id).id } } + + it_behaves_like 'a service approving an access request' do + let(:source) { project } + end + end + end + end +end diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb index 2395445e7fd..9995f3488af 100644 --- a/spec/services/members/destroy_service_spec.rb +++ b/spec/services/members/destroy_service_spec.rb @@ -2,70 +2,111 @@ require 'spec_helper' describe Members::DestroyService, services: true do let(:user) { create(:user) } - let(:project) { create(:project) } - let!(:member) { create(:project_member, source: project) } + let(:member_user) { create(:user) } + let(:project) { create(:project, :public) } + let(:group) { create(:group, :public) } - context 'when member is nil' do - before do - project.team << [user, :developer] + shared_examples 'a service raising ActiveRecord::RecordNotFound' do + it 'raises ActiveRecord::RecordNotFound' do + expect { described_class.new(source, user, params).execute }.to raise_error(ActiveRecord::RecordNotFound) end + end - it 'does not destroy the member' do - expect { destroy_member(nil, user) }.to raise_error(Gitlab::Access::AccessDeniedError) + shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do + it 'raises Gitlab::Access::AccessDeniedError' do + expect { described_class.new(source, user, params).execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end - context 'when current user cannot destroy the given member' do - before do - project.team << [user, :developer] + shared_examples 'a service destroying a member' do + it 'destroys the member' do + expect { described_class.new(source, user, params).execute }.to change { source.members.count }.by(-1) + end + + context 'when the given member is an access requester' do + before do + source.members.find_by(user_id: member_user).destroy + source.request_access(member_user) + end + let(:access_requester) { source.requesters.find_by(user_id: member_user) } + + it_behaves_like 'a service raising ActiveRecord::RecordNotFound' + + %i[requesters all].each do |scope| + context "and #{scope} scope is passed" do + it 'destroys the access requester' do + expect { described_class.new(source, user, params).execute(scope) }.to change { source.requesters.count }.by(-1) + end + + it 'calls Member#after_decline_request' do + expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(access_requester) + + described_class.new(source, user, params).execute(scope) + end + + context 'when current user is the member' do + it 'does not call Member#after_decline_request' do + expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(access_requester) + + described_class.new(source, member_user, params).execute(scope) + end + end + end + end end + end + + context 'when no member are found' do + let(:params) { { user_id: 42 } } - it 'does not destroy the member' do - expect { destroy_member(member, user) }.to raise_error(Gitlab::Access::AccessDeniedError) + it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do + let(:source) { project } + end + + it_behaves_like 'a service raising ActiveRecord::RecordNotFound' do + let(:source) { group } end end - context 'when current user can destroy the given member' do + context 'when a member is found' do before do - project.team << [user, :master] + project.team << [member_user, :developer] + group.add_developer(member_user) end + let(:params) { { user_id: member_user.id } } - it 'destroys the member' do - destroy_member(member, user) + context 'when current user cannot destroy the given member' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { project } + end - expect(member).to be_destroyed + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { group } + end end - context 'when the given member is a requester' do + context 'when current user can destroy the given member' do before do - member.update_column(:requested_at, Time.now) + project.team << [user, :master] + group.add_owner(user) end - it 'calls Member#after_decline_request' do - expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(member) - - destroy_member(member, user) + it_behaves_like 'a service destroying a member' do + let(:source) { project } end - context 'when current user is the member' do - it 'does not call Member#after_decline_request' do - expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(member) - - destroy_member(member, member.user) - end + it_behaves_like 'a service destroying a member' do + let(:source) { group } end - context 'when current user is the member and ' do - it 'does not call Member#after_decline_request' do - expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(member) + context 'when given a :id' do + let(:params) { { id: project.members.find_by!(user_id: user.id).id } } - destroy_member(member, member.user) + it 'destroys the member' do + expect { described_class.new(project, user, params).execute }. + to change { project.members.count }.by(-1) end end end end - - def destroy_member(member, user) - Members::DestroyService.new(member, user).execute - end end diff --git a/spec/services/members/request_access_service_spec.rb b/spec/services/members/request_access_service_spec.rb new file mode 100644 index 00000000000..0d2d5f03199 --- /dev/null +++ b/spec/services/members/request_access_service_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Members::RequestAccessService, services: true do + let(:user) { create(:user) } + let(:project) { create(:project, :private) } + let(:group) { create(:group, :private) } + + shared_examples 'a service raising Gitlab::Access::AccessDeniedError' do + it 'raises Gitlab::Access::AccessDeniedError' do + expect { described_class.new(source, user).execute }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + shared_examples 'a service creating a access request' do + it 'succeeds' do + expect { described_class.new(source, user).execute }.to change { source.requesters.count }.by(1) + end + + it 'returns a <Source>Member' do + member = described_class.new(source, user).execute + + expect(member).to be_a "#{source.class}Member".constantize + expect(member.requested_at).to be_present + end + end + + context 'when source is nil' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { nil } + end + end + + context 'when current user cannot request access to the project' do + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { project } + end + + it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do + let(:source) { group } + end + end + + context 'when current user can request access to the project' do + before do + project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + group.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + + it_behaves_like 'a service creating a access request' do + let(:source) { project } + end + + it_behaves_like 'a service creating a access request' do + let(:source) { group } + end + end +end diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb new file mode 100644 index 00000000000..7aeb95a15ea --- /dev/null +++ b/spec/services/merge_requests/assign_issues_service_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe MergeRequests::AssignIssuesService, services: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:issue) { create(:issue, project: project) } + let(:merge_request) { create(:merge_request, :simple, source_project: project, author: user, description: "fixes #{issue.to_reference}") } + let(:service) { described_class.new(project, user, merge_request: merge_request) } + + before do + project.team << [user, :developer] + end + + it 'finds unassigned issues fixed in merge request' do + expect(service.assignable_issues.map(&:id)).to include(issue.id) + end + + it 'ignores issues already assigned to any user' do + issue.update!(assignee: create(:user)) + + expect(service.assignable_issues).to be_empty + end + + it 'ignores issues the user cannot update assignee on' do + project.team.truncate + + expect(service.assignable_issues).to be_empty + end + + it 'ignores all issues unless current_user is merge_request.author' do + merge_request.update!(author: create(:user)) + + expect(service.assignable_issues).to be_empty + end + + it 'accepts precomputed data for closes_issues' do + issue2 = create(:issue, project: project) + service2 = described_class.new(project, + user, + merge_request: merge_request, + closes_issues: [issue, issue2]) + + expect(service2.assignable_issues.count).to eq 2 + end + + it 'assigns these to the merge request owner' do + expect { service.execute }.to change { issue.reload.assignee }.to(user) + end +end diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index 232508cda23..3a3f07ddcb9 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -52,12 +52,28 @@ describe MergeRequests::BuildService, services: true do end end - context 'no commits in the diff' do - let(:commits) { [] } + context 'same source and target branch' do + let(:source_branch) { 'master' } it 'forbids the merge request from being created' do expect(merge_request.can_be_created).to eq(false) end + + it 'adds an error message to the merge request' do + expect(merge_request.errors).to contain_exactly('You must select different branches') + end + end + + context 'no commits in the diff' do + let(:commits) { [] } + + it 'allows the merge request to be created' do + expect(merge_request.can_be_created).to eq(true) + end + + it 'adds a WIP prefix to the merge request title' do + expect(merge_request.title).to eq('WIP: Feature branch') + end end context 'one commit in the diff' do @@ -99,14 +115,14 @@ describe MergeRequests::BuildService, services: true do let(:source_branch) { "#{issue.iid}-fix-issue" } it 'appends "Closes #$issue-iid" to the description' do - expect(merge_request.description).to eq("#{commit_1.safe_message.split(/\n+/, 2).last}\nCloses ##{issue.iid}") + expect(merge_request.description).to eq("#{commit_1.safe_message.split(/\n+/, 2).last}\n\nCloses ##{issue.iid}") end context 'merge request already has a description set' do let(:description) { 'Merge request description' } it 'appends "Closes #$issue-iid" to the description' do - expect(merge_request.description).to eq("#{description}\nCloses ##{issue.iid}") + expect(merge_request.description).to eq("#{description}\n\nCloses ##{issue.iid}") end end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index c1db4f3284b..24c25e4350f 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe MergeRequests::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } + let(:guest) { create(:user) } let(:merge_request) { create(:merge_request, assignee: user2) } let(:project) { merge_request.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) } @@ -10,11 +11,12 @@ describe MergeRequests::CloseService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context 'valid params' do - let(:service) { MergeRequests::CloseService.new(project, user, {}) } + let(:service) { described_class.new(project, user, {}) } before do allow(service).to receive(:execute_hooks) @@ -32,13 +34,13 @@ describe MergeRequests::CloseService, services: true do with(@merge_request, 'close') end - it 'should send email to user2 about assign of new merge_request' do + it 'sends email to user2 about assign of new merge_request' do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(merge_request.title) end - it 'should create system note about merge_request reassign' do + it 'creates system note about merge_request reassign' do note = @merge_request.notes.last expect(note.note).to include 'Status changed to closed' end @@ -47,5 +49,17 @@ describe MergeRequests::CloseService, services: true do expect(todo.reload).to be_done end end + + context 'current user is not authorized to close merge request' do + before do + perform_enqueued_jobs do + @merge_request = described_class.new(project, guest).execute(merge_request) + end + end + + it 'does not close the merge request' do + expect(@merge_request).to be_open + end + end end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 872732c6e94..18d4227a2ed 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do } end - let(:service) { MergeRequests::CreateService.new(project, user, opts) } + let(:service) { described_class.new(project, user, opts) } before do project.team << [user, :master] @@ -32,7 +32,7 @@ describe MergeRequests::CreateService, services: true do it { expect(@merge_request.assignee).to be_nil } it { expect(@merge_request.remove_source_branch).to be true } - it 'should execute hooks with default action' do + it 'executes hooks with default action' do expect(service).to have_received(:execute_hooks).with(@merge_request) end @@ -74,5 +74,43 @@ describe MergeRequests::CreateService, services: true do end end end + + it_behaves_like 'new issuable record that supports slash commands' do + let(:default_params) do + { + source_branch: 'feature', + target_branch: 'master' + } + end + end + + context 'while saving references to issues that the created merge request closes' do + let(:first_issue) { create(:issue, project: project) } + let(:second_issue) { create(:issue, project: project) } + + let(:opts) do + { + title: 'Awesome merge_request', + source_branch: 'feature', + target_branch: 'master', + force_remove_source_branch: '1' + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :developer] + end + + it 'creates a `MergeRequestsClosingIssues` record for each issue' do + issue_closing_opts = opts.merge(description: "Closes #{first_issue.to_reference} and #{second_issue.to_reference}") + service = described_class.new(project, user, issue_closing_opts) + allow(service).to receive(:execute_hooks) + merge_request = service.execute + + issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id) + expect(issue_ids).to match_array([first_issue.id, second_issue.id]) + end + end end end diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb new file mode 100644 index 00000000000..3a71776e81f --- /dev/null +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -0,0 +1,134 @@ +require "spec_helper" + +describe MergeRequests::GetUrlsService do + let(:project) { create(:project, :public) } + let(:service) { MergeRequests::GetUrlsService.new(project) } + let(:source_branch) { "my_branch" } + let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } + let(:show_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } + let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } + let(:deleted_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 #{Gitlab::Git::BLANK_SHA} refs/heads/#{source_branch}" } + let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } + let(:default_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master" } + + describe "#execute" do + shared_examples 'new_merge_request_link' do + it 'returns url to create new merge request' do + result = service.execute(changes) + expect(result).to match([{ + branch_name: source_branch, + url: new_merge_request_url, + new_merge_request: true + }]) + end + end + + shared_examples 'show_merge_request_url' do + it 'returns url to view merge request' do + result = service.execute(changes) + expect(result).to match([{ + branch_name: source_branch, + url: show_merge_request_url, + new_merge_request: false + }]) + end + end + + shared_examples 'no_merge_request_url' do + it 'returns no URL' do + result = service.execute(changes) + expect(result).to be_empty + end + end + + context 'pushing to default branch' do + let(:changes) { default_branch_changes } + it_behaves_like 'no_merge_request_url' + end + + context 'pushing to project with MRs disabled' do + let(:changes) { new_branch_changes } + + before do + project.project_feature.update_attribute(:merge_requests_access_level, ProjectFeature::DISABLED) + end + + it_behaves_like 'no_merge_request_url' + end + + context 'pushing one completely new branch' do + let(:changes) { new_branch_changes } + it_behaves_like 'new_merge_request_link' + end + + context 'pushing to existing branch but no merge request' do + let(:changes) { existing_branch_changes } + it_behaves_like 'new_merge_request_link' + end + + context 'pushing to deleted branch' do + let(:changes) { deleted_branch_changes } + it_behaves_like 'no_merge_request_url' + end + + context 'pushing to existing branch and merge request opened' do + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: source_branch) } + let(:changes) { existing_branch_changes } + it_behaves_like 'show_merge_request_url' + end + + context 'pushing to existing branch and merge request is reopened' do + let!(:merge_request) { create(:merge_request, :reopened, source_project: project, source_branch: source_branch) } + let(:changes) { existing_branch_changes } + it_behaves_like 'show_merge_request_url' + end + + context 'pushing to existing branch from forked project' do + let(:user) { create(:user) } + let!(:forked_project) { Projects::ForkService.new(project, user).execute } + let!(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project, source_branch: source_branch) } + let(:changes) { existing_branch_changes } + # Source project is now the forked one + let(:service) { MergeRequests::GetUrlsService.new(forked_project) } + + before do + allow(forked_project).to receive(:empty_repo?).and_return(false) + end + + it_behaves_like 'show_merge_request_url' + end + + context 'pushing to existing branch and merge request is closed' do + let!(:merge_request) { create(:merge_request, :closed, source_project: project, source_branch: source_branch) } + let(:changes) { existing_branch_changes } + it_behaves_like 'new_merge_request_link' + end + + context 'pushing to existing branch and merge request is merged' do + let!(:merge_request) { create(:merge_request, :merged, source_project: project, source_branch: source_branch) } + let(:changes) { existing_branch_changes } + it_behaves_like 'new_merge_request_link' + end + + context 'pushing new branch and existing branch (with merge request created) at once' do + let!(:merge_request) { create(:merge_request, source_project: project, source_branch: "existing_branch") } + let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" } + let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/existing_branch" } + let(:changes) { "#{new_branch_changes}\n#{existing_branch_changes}" } + let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" } + + it 'returns 2 urls for both creating new and showing merge request' do + result = service.execute(changes) + expect(result).to match([{ + branch_name: "new_branch", + url: new_merge_request_url, + new_merge_request: true + }, { + branch_name: "existing_branch", + url: show_merge_request_url, + new_merge_request: false + }]) + end + end + end +end diff --git a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb index 8f71d71b0f0..807f89e80b7 100644 --- a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb +++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb @@ -1,13 +1,12 @@ require 'spec_helper' describe MergeRequests::MergeRequestDiffCacheService do - let(:subject) { MergeRequests::MergeRequestDiffCacheService.new } describe '#execute' do it 'retrieves the diff files to cache the highlighted result' do merge_request = create(:merge_request) - cache_key = [merge_request.merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::FileCollection::MergeRequest.default_options] + cache_key = [merge_request.merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::FileCollection::MergeRequestDiff.default_options] expect(Rails.cache).to receive(:read).with(cache_key).and_return({}) expect(Rails.cache).to receive(:write).with(cache_key, anything) diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 78f97df09ed..7b15cde6593 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -26,18 +26,81 @@ describe MergeRequests::MergeService, services: true do it { expect(merge_request).to be_valid } it { expect(merge_request).to be_merged } - it 'should send email to user2 about merge of new merge_request' do + it 'sends email to user2 about merge of new merge_request' do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(merge_request.title) end - it 'should create system note about merge_request merge' do + it 'creates system note about merge_request merge' do note = merge_request.notes.last expect(note.note).to include 'Status changed to merged' end end + context 'closes related issues' do + let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } + + before do + allow(project).to receive(:default_branch).and_return(merge_request.target_branch) + end + + it 'closes GitLab issue tracker issues' do + issue = create :issue, project: project + commit = double('commit', safe_message: "Fixes #{issue.to_reference}") + allow(merge_request).to receive(:commits).and_return([commit]) + + service.execute(merge_request) + + expect(issue.reload.closed?).to be_truthy + end + + context 'with JIRA integration' do + include JiraServiceHelper + + let(:jira_tracker) { project.create_jira_service } + + before do + project.update_attributes!(has_external_issue_tracker: true) + jira_service_settings + end + + it 'closes issues on JIRA issue tracker' do + jira_issue = ExternalIssue.new('JIRA-123', project) + commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}") + allow(merge_request).to receive(:commits).and_return([commit]) + + expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, jira_issue).once + + service.execute(merge_request) + end + end + end + + context 'closes related todos' do + let(:merge_request) { create(:merge_request, assignee: user, author: user) } + let(:project) { merge_request.project } + let(:service) { MergeRequests::MergeService.new(project, user, commit_message: 'Awesome message') } + let!(:todo) do + create(:todo, :assigned, + project: project, + author: user, + user: user, + target: merge_request) + end + + before do + allow(service).to receive(:execute_hooks) + + perform_enqueued_jobs do + service.execute(merge_request) + todo.reload + end + end + + it { expect(todo).to be_done } + end + context 'remove source branch by author' do let(:service) do merge_request.merge_params['force_remove_source_branch'] = '1' diff --git a/spec/services/merge_requests/merge_when_build_succeeds_service_spec.rb b/spec/services/merge_requests/merge_when_build_succeeds_service_spec.rb index 631642466e1..e0d81dc1a43 100644 --- a/spec/services/merge_requests/merge_when_build_succeeds_service_spec.rb +++ b/spec/services/merge_requests/merge_when_build_succeeds_service_spec.rb @@ -56,100 +56,98 @@ describe MergeRequests::MergeWhenBuildSucceedsService do end describe "#trigger" do - context 'build with ref' do - let(:build) { create(:ci_build, ref: mr_merge_if_green_enabled.source_branch, status: "success") } + let(:merge_request_ref) { mr_merge_if_green_enabled.source_branch } + let(:merge_request_head) do + project.commit(mr_merge_if_green_enabled.source_branch).id + end - it "merges all merge requests with merge when build succeeds enabled" do - allow_any_instance_of(MergeRequest).to receive(:pipeline).and_return(pipeline) - allow(pipeline).to receive(:success?).and_return(true) + context 'when triggered by pipeline with valid ref and sha' do + let(:triggering_pipeline) do + create(:ci_pipeline, project: project, ref: merge_request_ref, + sha: merge_request_head, status: 'success') + end + it "merges all merge requests with merge when build succeeds enabled" do expect(MergeWorker).to receive(:perform_async) - service.trigger(build) + service.trigger(triggering_pipeline) end end - context 'triggered by an old build' do - let(:old_build) { create(:ci_build, ref: mr_merge_if_green_enabled.source_branch, status: "success") } - let(:build) { create(:ci_build, ref: mr_merge_if_green_enabled.source_branch, status: "success") } - - it "merges all merge requests with merge when build succeeds enabled" do - allow_any_instance_of(MergeRequest).to receive(:pipeline).and_return(pipeline) - allow(pipeline).to receive(:success?).and_return(true) - allow(old_build).to receive(:sha).and_return('1234abcdef') + context 'when triggered by an old pipeline' do + let(:old_pipeline) do + create(:ci_pipeline, project: project, ref: merge_request_ref, + sha: '1234abcdef', status: 'success') + end + it 'it does not merge merge request' do expect(MergeWorker).not_to receive(:perform_async) - service.trigger(old_build) + service.trigger(old_pipeline) end end - context 'commit status without ref' do - let(:commit_status) { create(:generic_commit_status, status: 'success') } - - before { mr_merge_if_green_enabled } - - it "doesn't merge a requests for status on other branch" do - allow(project.repository).to receive(:branch_names_contains).with(commit_status.sha).and_return([]) + context 'when triggered by pipeline from a different branch' do + let(:unrelated_pipeline) do + create(:ci_pipeline, project: project, ref: 'feature', + sha: merge_request_head, status: 'success') + end + it 'does not merge request' do expect(MergeWorker).not_to receive(:perform_async) - service.trigger(commit_status) + service.trigger(unrelated_pipeline) end + end + end - it 'discovers branches and merges all merge requests when status is success' do - allow(project.repository).to receive(:branch_names_contains). - with(commit_status.sha).and_return([mr_merge_if_green_enabled.source_branch]) - allow(pipeline).to receive(:success?).and_return(true) - allow_any_instance_of(MergeRequest).to receive(:pipeline).and_return(pipeline) - allow(pipeline).to receive(:success?).and_return(true) + describe "#cancel" do + before do + service.cancel(mr_merge_if_green_enabled) + end - expect(MergeWorker).to receive(:perform_async) - service.trigger(commit_status) - end + it 'Posts a system note' do + note = mr_merge_if_green_enabled.notes.last + expect(note.note).to include 'Canceled the automatic merge' end + end - context 'properly handles multiple stages' do + describe 'pipeline integration' do + context 'when there are multiple stages in the pipeline' do let(:ref) { mr_merge_if_green_enabled.source_branch } - let(:build) { create(:ci_build, pipeline: pipeline, ref: ref, name: 'build', stage: 'build') } - let(:test) { create(:ci_build, pipeline: pipeline, ref: ref, name: 'test', stage: 'test') } + let(:sha) { project.commit(ref).id } + + let(:pipeline) do + create(:ci_empty_pipeline, ref: ref, sha: sha, project: project) + end + + let!(:build) do + create(:ci_build, :created, pipeline: pipeline, ref: ref, + name: 'build', stage: 'build') + end + + let!(:test) do + create(:ci_build, :created, pipeline: pipeline, ref: ref, + name: 'test', stage: 'test') + end before do # This behavior of MergeRequest: we instantiate a new object allow_any_instance_of(MergeRequest).to receive(:pipeline).and_wrap_original do Ci::Pipeline.find(pipeline.id) end - - # We create test after the build - allow(pipeline).to receive(:create_next_builds).and_wrap_original do - test - end end - it "doesn't merge if some stages failed" do + it "doesn't merge if any of stages failed" do expect(MergeWorker).not_to receive(:perform_async) + build.success test.drop end - it 'merge when all stages succeeded' do + it 'merges when all stages succeeded' do expect(MergeWorker).to receive(:perform_async) + build.success test.success end end end - - describe "#cancel" do - before do - service.cancel(mr_merge_if_green_enabled) - end - - it "resets all the merge_when_build_succeeds params" do - expect(mr_merge_if_green_enabled.merge_when_build_succeeds).to be_falsey - expect(mr_merge_if_green_enabled.merge_user).to be nil - end - - it 'Posts a system note' do - note = mr_merge_if_green_enabled.notes.last - expect(note.note).to include 'Canceled the automatic merge' - end - end end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 781ee7ffed3..e515bc9f89c 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -55,14 +55,15 @@ describe MergeRequests::RefreshService, services: true do reload_mrs end - it 'should execute hooks with update action' do + it 'executes hooks with update action' do expect(refresh_service).to have_received(:execute_hooks). with(@merge_request, 'update', @oldrev) end it { expect(@merge_request.notes).not_to be_empty } it { expect(@merge_request).to be_open } - it { expect(@merge_request.merge_when_build_succeeds).to be_falsey} + it { expect(@merge_request.merge_when_build_succeeds).to be_falsey } + it { expect(@merge_request.diff_head_sha).to eq(@newrev) } it { expect(@fork_merge_request).to be_open } it { expect(@fork_merge_request.notes).to be_empty } it { expect(@build_failed_todo).to be_done } @@ -79,8 +80,8 @@ describe MergeRequests::RefreshService, services: true do it { expect(@merge_request).to be_merged } it { expect(@fork_merge_request).to be_merged } it { expect(@fork_merge_request.notes.last.note).to include('changed to merged') } - it { expect(@build_failed_todo).to be_pending } - it { expect(@fork_build_failed_todo).to be_pending } + it { expect(@build_failed_todo).to be_done } + it { expect(@fork_build_failed_todo).to be_done } end context 'manual merge of source branch' do @@ -99,8 +100,8 @@ describe MergeRequests::RefreshService, services: true do it { expect(@merge_request.diffs.size).to be > 0 } it { expect(@fork_merge_request).to be_merged } it { expect(@fork_merge_request.notes.last.note).to include('changed to merged') } - it { expect(@build_failed_todo).to be_pending } - it { expect(@fork_build_failed_todo).to be_pending } + it { expect(@build_failed_todo).to be_done } + it { expect(@fork_build_failed_todo).to be_done } end context 'push to fork repo source branch' do @@ -111,14 +112,14 @@ describe MergeRequests::RefreshService, services: true do reload_mrs end - it 'should execute hooks with update action' do + it 'executes hooks with update action' do expect(refresh_service).to have_received(:execute_hooks). with(@fork_merge_request, 'update', @oldrev) end it { expect(@merge_request.notes).to be_empty } it { expect(@merge_request).to be_open } - it { expect(@fork_merge_request.notes.last.note).to include('Added 4 commits') } + it { expect(@fork_merge_request.notes.last.note).to include('Added 28 commits') } it { expect(@fork_merge_request).to be_open } it { expect(@build_failed_todo).to be_pending } it { expect(@fork_build_failed_todo).to be_pending } @@ -149,8 +150,8 @@ describe MergeRequests::RefreshService, services: true do it { expect(@merge_request).to be_merged } it { expect(@fork_merge_request).to be_open } it { expect(@fork_merge_request.notes).to be_empty } - it { expect(@build_failed_todo).to be_pending } - it { expect(@fork_build_failed_todo).to be_pending } + it { expect(@build_failed_todo).to be_done } + it { expect(@fork_build_failed_todo).to be_done } end context 'push new branch that exists in a merge request' do @@ -169,11 +170,63 @@ describe MergeRequests::RefreshService, services: true do notes = @fork_merge_request.notes.reorder(:created_at).map(&:note) expect(notes[0]).to include('Restored source branch `master`') - expect(notes[1]).to include('Added 4 commits') + expect(notes[1]).to include('Added 28 commits') expect(@fork_merge_request).to be_open end end + context 'merge request metrics' do + let(:issue) { create :issue, project: @project } + let(:commit_author) { create :user } + let(:commit) { project.commit } + + before do + project.team << [commit_author, :developer] + project.team << [user, :developer] + + allow(commit).to receive_messages( + safe_message: "Closes #{issue.to_reference}", + references: [issue], + author_name: commit_author.name, + author_email: commit_author.email, + committed_date: Time.now + ) + + allow_any_instance_of(MergeRequest).to receive(:commits).and_return([commit]) + end + + context 'when the merge request is sourced from the same project' do + it 'creates a `MergeRequestsClosingIssues` record for each issue closed by a commit' do + merge_request = create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: @project) + refresh_service = service.new(@project, @user) + allow(refresh_service).to receive(:execute_hooks) + refresh_service.execute(@oldrev, @newrev, 'refs/heads/feature') + + issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id) + expect(issue_ids).to eq([issue.id]) + end + end + + context 'when the merge request is sourced from a different project' do + it 'creates a `MergeRequestsClosingIssues` record for each issue closed by a commit' do + forked_project = create(:project) + create(:forked_project_link, forked_to_project: forked_project, forked_from_project: @project) + + merge_request = create(:merge_request, + target_branch: 'master', + source_branch: 'feature', + target_project: @project, + source_project: forked_project) + refresh_service = service.new(@project, @user) + allow(refresh_service).to receive(:execute_hooks) + refresh_service.execute(@oldrev, @newrev, 'refs/heads/feature') + + issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id) + expect(issue_ids).to eq([issue.id]) + end + end + end + def reload_mrs @merge_request.reload @fork_merge_request.reload diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index 88c9c640514..af7424a76a9 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -3,22 +3,23 @@ require 'spec_helper' describe MergeRequests::ReopenService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } - let(:merge_request) { create(:merge_request, assignee: user2) } + let(:guest) { create(:user) } + let(:merge_request) { create(:merge_request, :closed, assignee: user2) } let(:project) { merge_request.project } before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context 'valid params' do - let(:service) { MergeRequests::ReopenService.new(project, user, {}) } + let(:service) { described_class.new(project, user, {}) } before do allow(service).to receive(:execute_hooks) - merge_request.state = :closed perform_enqueued_jobs do service.execute(merge_request) end @@ -27,21 +28,33 @@ describe MergeRequests::ReopenService, services: true do it { expect(merge_request).to be_valid } it { expect(merge_request).to be_reopened } - it 'should execute hooks with reopen action' do + it 'executes hooks with reopen action' do expect(service).to have_received(:execute_hooks). with(merge_request, 'reopen') end - it 'should send email to user2 about reopen of merge_request' do + it 'sends email to user2 about reopen of merge_request' do email = ActionMailer::Base.deliveries.last expect(email.to.first).to eq(user2.email) expect(email.subject).to include(merge_request.title) end - it 'should create system note about merge_request reopen' do + it 'creates system note about merge_request reopen' do note = merge_request.notes.last expect(note.note).to include 'Status changed to reopened' end end + + context 'current user is not authorized to reopen merge request' do + before do + perform_enqueued_jobs do + @merge_request = described_class.new(project, guest).execute(merge_request) + end + end + + it 'does not reopen the merge request' do + expect(@merge_request).to be_closed + end + end end end diff --git a/spec/services/merge_requests/resolve_service_spec.rb b/spec/services/merge_requests/resolve_service_spec.rb new file mode 100644 index 00000000000..d71932458fa --- /dev/null +++ b/spec/services/merge_requests/resolve_service_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +describe MergeRequests::ResolveService do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:fork_project) do + create(:forked_project_with_submodules) do |fork_project| + fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) + fork_project.save + end + end + + let(:merge_request) do + create(:merge_request, + source_branch: 'conflict-resolvable', source_project: project, + target_branch: 'conflict-start') + end + + let(:merge_request_from_fork) do + create(:merge_request, + source_branch: 'conflict-resolvable-fork', source_project: fork_project, + target_branch: 'conflict-start', target_project: project) + end + + describe '#execute' do + context 'with valid params' do + let(:params) do + { + sections: { + '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' + }, + commit_message: 'This is a commit message!' + } + end + + context 'when the source and target project are the same' do + before do + MergeRequests::ResolveService.new(project, user, params).execute(merge_request) + end + + it 'creates a commit with the message' do + expect(merge_request.source_branch_head.message).to eq(params[:commit_message]) + end + + it 'creates a commit with the correct parents' do + expect(merge_request.source_branch_head.parents.map(&:id)). + to eq(['1450cd639e0bc6721eb02800169e464f212cde06', + '75284c70dd26c87f2a3fb65fd5a1f0b0138d3a6b']) + end + end + + context 'when the source project is a fork and does not contain the HEAD of the target branch' do + let!(:target_head) do + project.repository.commit_file(user, 'new-file-in-target', '', 'Add new file in target', 'conflict-start', false) + end + + before do + MergeRequests::ResolveService.new(fork_project, user, params).execute(merge_request_from_fork) + end + + it 'creates a commit with the message' do + expect(merge_request_from_fork.source_branch_head.message).to eq(params[:commit_message]) + end + + it 'creates a commit with the correct parents' do + expect(merge_request_from_fork.source_branch_head.parents.map(&:id)). + to eq(['404fa3fc7c2c9b5dacff102f353bdf55b1be2813', + target_head]) + end + end + end + + context 'when a resolution is missing' do + let(:invalid_params) { { sections: { '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head' } } } + let(:service) { MergeRequests::ResolveService.new(project, user, invalid_params) } + + it 'raises a MissingResolution error' do + expect { service.execute(merge_request) }. + to raise_error(Gitlab::Conflict::File::MissingResolution) + end + end + end +end diff --git a/spec/services/merge_requests/resolved_discussion_notification_service.rb b/spec/services/merge_requests/resolved_discussion_notification_service.rb new file mode 100644 index 00000000000..7ddd812e513 --- /dev/null +++ b/spec/services/merge_requests/resolved_discussion_notification_service.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe MergeRequests::ResolvedDiscussionNotificationService, services: true do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + let(:project) { merge_request.project } + subject { described_class.new(project, user) } + + describe "#execute" do + context "when not all discussions are resolved" do + before do + allow(merge_request).to receive(:discussions_resolved?).and_return(false) + end + + it "doesn't add a system note" do + expect(SystemNoteService).not_to receive(:resolve_all_discussions) + + subject.execute(merge_request) + end + + it "doesn't send a notification email" do + expect_any_instance_of(NotificationService).not_to receive(:resolve_all_discussions) + + subject.execute(merge_request) + end + end + + context "when all discussions are resolved" do + before do + allow(merge_request).to receive(:discussions_resolved?).and_return(true) + end + + it "adds a system note" do + expect(SystemNoteService).to receive(:resolve_all_discussions).with(merge_request, project, user) + + subject.execute(merge_request) + end + + it "sends a notification email" do + expect_any_instance_of(NotificationService).to receive(:resolve_all_discussions).with(merge_request, user) + + subject.execute(merge_request) + end + end + end +end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 265425656b7..15d6e5bedae 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -17,6 +17,7 @@ describe MergeRequests::UpdateService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [user3, :developer] end describe 'execute' do @@ -64,12 +65,12 @@ describe MergeRequests::UpdateService, services: true do it { expect(@merge_request.target_branch).to eq('target') } it { expect(@merge_request.remove_source_branch).to be_truthy } - it 'should execute hooks with update action' do + it 'executes hooks with update action' do expect(service).to have_received(:execute_hooks). with(@merge_request, 'update') end - it 'should send email to user2 about assign of new merge request and email to user3 about merge request unassignment' do + it 'sends email to user2 about assign of new merge request and email to user3 about merge request unassignment' do deliveries = ActionMailer::Base.deliveries email = deliveries.last recipients = deliveries.last(2).map(&:to).flatten @@ -77,14 +78,14 @@ describe MergeRequests::UpdateService, services: true do expect(email.subject).to include(merge_request.title) end - it 'should create system note about merge_request reassign' do + it 'creates system note about merge_request reassign' do note = find_note('Reassigned to') expect(note).not_to be_nil expect(note.note).to include "Reassigned to \@#{user2.username}" end - it 'should create system note about merge_request label edit' do + it 'creates system note about merge_request label edit' do note = find_note('Added ~') expect(note).not_to be_nil @@ -104,6 +105,18 @@ describe MergeRequests::UpdateService, services: true do expect(note).not_to be_nil expect(note.note).to eq 'Target branch changed from `master` to `target`' end + + context 'when not including source branch removal options' do + before do + opts.delete(:force_remove_source_branch) + end + + it 'maintains the original options' do + update_merge_request(opts) + + expect(@merge_request.merge_params["force_remove_source_branch"]).to eq("1") + end + end end context 'todos' do @@ -188,6 +201,11 @@ describe MergeRequests::UpdateService, services: true do let!(:non_subscriber) { create(:user) } let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } } + before do + project.team << [non_subscriber, :developer] + project.team << [subscriber, :developer] + end + it 'sends notifications for subscribers of newly added labels' do opts = { label_ids: [label.id] } @@ -226,6 +244,11 @@ describe MergeRequests::UpdateService, services: true do end end + context 'updating mentions' do + let(:mentionable) { merge_request } + include_examples 'updating mentions', MergeRequests::UpdateService + end + context 'when MergeRequest has tasks' do before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) } @@ -258,5 +281,42 @@ describe MergeRequests::UpdateService, services: true do end end end + + context 'while saving references to issues that the updated merge request closes' do + let(:first_issue) { create(:issue, project: project) } + let(:second_issue) { create(:issue, project: project) } + + it 'creates a `MergeRequestsClosingIssues` record for each issue' do + issue_closing_opts = { description: "Closes #{first_issue.to_reference} and #{second_issue.to_reference}" } + service = described_class.new(project, user, issue_closing_opts) + allow(service).to receive(:execute_hooks) + service.execute(merge_request) + + issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id) + expect(issue_ids).to match_array([first_issue.id, second_issue.id]) + end + + it 'removes `MergeRequestsClosingIssues` records when issues are not closed anymore' do + opts = { + title: 'Awesome merge_request', + description: "Closes #{first_issue.to_reference} and #{second_issue.to_reference}", + source_branch: 'feature', + target_branch: 'master', + force_remove_source_branch: '1' + } + + merge_request = MergeRequests::CreateService.new(project, user, opts).execute + + issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id) + expect(issue_ids).to match_array([first_issue.id, second_issue.id]) + + service = described_class.new(project, user, description: "not closing any issues") + allow(service).to receive(:execute_hooks) + service.execute(merge_request.reload) + + issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id) + expect(issue_ids).to be_empty + end + end end end diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 32753e84b31..93885c84dc3 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -4,22 +4,36 @@ describe Notes::CreateService, services: true do let(:project) { create(:empty_project) } let(:issue) { create(:issue, project: project) } let(:user) { create(:user) } + let(:opts) do + { note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id } + end describe '#execute' do + before do + project.team << [user, :master] + end + context "valid params" do before do - project.team << [user, :master] - opts = { - note: 'Awesome comment', - noteable_type: 'Issue', - noteable_id: issue.id - } - @note = Notes::CreateService.new(project, user, opts).execute end it { expect(@note).to be_valid } - it { expect(@note.note).to eq('Awesome comment') } + it { expect(@note.note).to eq(opts[:note]) } + end + + describe 'note with commands' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } + + it 'saves the note and does not alter the note text' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original + + note = described_class.new(project, user, opts.merge(note: note_text)).execute + + expect(note.note).to eq "HELLO\nWORLD" + end + end end end @@ -42,7 +56,7 @@ describe Notes::CreateService, services: true do it "creates regular note if emoji name is invalid" do opts = { - note: ':smile: moretext: ', + note: ':smile: moretext:', noteable_type: 'Issue', noteable_id: issue.id } diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb new file mode 100644 index 00000000000..d1099884a02 --- /dev/null +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -0,0 +1,209 @@ +require 'spec_helper' + +describe Notes::SlashCommandsService, services: true do + shared_context 'note on noteable' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + end + + shared_examples 'note on noteable that does not support slash commands' do + include_context 'note on noteable' + + before do + note.note = note_text + end + + describe 'note with only command' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(/close\n/assign @#{assignee.username}") } + + it 'saves the note and does not alter the note text' do + content, command_params = service.extract_commands(note) + + expect(content).to eq note_text + expect(command_params).to be_empty + end + end + end + + describe 'note with command & text' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) } + + it 'saves the note and does not alter the note text' do + content, command_params = service.extract_commands(note) + + expect(content).to eq note_text + expect(command_params).to be_empty + end + end + end + end + + shared_examples 'note on noteable that supports slash commands' do + include_context 'note on noteable' + + before do + note.note = note_text + end + + let!(:milestone) { create(:milestone, project: project) } + let!(:labels) { create_pair(:label, project: project) } + + describe 'note with only command' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) do + %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + end + + it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq '' + expect(note.noteable).to be_closed + expect(note.noteable.labels).to match_array(labels) + expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.milestone).to eq(milestone) + end + end + + describe '/reopen' do + before do + note.noteable.close! + expect(note.noteable).to be_closed + end + let(:note_text) { '/reopen' } + + it 'opens the noteable, and leave no note' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq '' + expect(note.noteable).to be_open + end + end + end + + describe 'note with command & text' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) do + %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD) + end + + it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq "HELLO\nWORLD" + expect(note.noteable).to be_closed + expect(note.noteable.labels).to match_array(labels) + expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.milestone).to eq(milestone) + end + end + + describe '/reopen' do + before do + note.noteable.close + expect(note.noteable).to be_closed + end + let(:note_text) { "HELLO\n/reopen\nWORLD" } + + it 'opens the noteable' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq "HELLO\nWORLD" + expect(note.noteable).to be_open + end + end + end + end + + describe '.noteable_update_service' do + include_context 'note on noteable' + + it 'returns Issues::UpdateService for a note on an issue' do + note = create(:note_on_issue, project: project) + + expect(described_class.noteable_update_service(note)).to eq(Issues::UpdateService) + end + + it 'returns Issues::UpdateService for a note on a merge request' do + note = create(:note_on_merge_request, project: project) + + expect(described_class.noteable_update_service(note)).to eq(MergeRequests::UpdateService) + end + + it 'returns nil for a note on a commit' do + note = create(:note_on_commit, project: project) + + expect(described_class.noteable_update_service(note)).to be_nil + end + end + + describe '.supported?' do + include_context 'note on noteable' + + let(:note) { create(:note_on_issue, project: project) } + + context 'with no current_user' do + it 'returns false' do + expect(described_class.supported?(note, nil)).to be_falsy + end + end + + context 'when current_user cannot update the noteable' do + it 'returns false' do + user = create(:user) + + expect(described_class.supported?(note, user)).to be_falsy + end + end + + context 'when current_user can update the noteable' do + it 'returns true' do + expect(described_class.supported?(note, master)).to be_truthy + end + + context 'with a note on a commit' do + let(:note) { create(:note_on_commit, project: project) } + + it 'returns false' do + expect(described_class.supported?(note, nil)).to be_falsy + end + end + end + end + + describe '#supported?' do + include_context 'note on noteable' + + it 'delegates to the class method' do + service = described_class.new(project, master) + note = create(:note_on_issue, project: project) + + expect(described_class).to receive(:supported?).with(note, master) + + service.supported?(note) + end + end + + describe '#execute' do + let(:service) { described_class.new(project, master) } + + it_behaves_like 'note on noteable that supports slash commands' do + let(:note) { build(:note_on_issue, project: project) } + end + + it_behaves_like 'note on noteable that supports slash commands' do + let(:note) { build(:note_on_merge_request, project: project) } + end + + it_behaves_like 'note on noteable that does not support slash commands' do + let(:note) { build(:note_on_commit, project: project) } + end + end +end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 9fc93f325f7..699b9925b4e 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -9,13 +9,35 @@ describe NotificationService, services: true do end end + shared_examples 'notifications for new mentions' do + def send_notifications(*new_mentions) + reset_delivered_emails! + notification.send(notification_method, mentionable, new_mentions, @u_disabled) + end + + it 'sends no emails when no new mentions are present' do + send_notifications + expect(ActionMailer::Base.deliveries).to be_empty + end + + it 'emails new mentions with a watch level higher than participant' do + send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global) + should_only_email(@u_watcher, @u_participant_mentioned, @u_custom_global) + end + + it 'does not email new mentions with a watch level equal to or less than participant' do + send_notifications(@u_participating, @u_mentioned) + expect(ActionMailer::Base.deliveries).to be_empty + end + end + describe 'Keys' do describe '#new_key' do let!(:key) { create(:personal_key) } it { expect(notification.new_key(key)).to be_truthy } - it 'should sent email to key owner' do + it 'sends email to key owner' do expect{ notification.new_key(key) }.to change{ ActionMailer::Base.deliveries.size }.by(1) end end @@ -27,7 +49,7 @@ describe NotificationService, services: true do it { expect(notification.new_email(email)).to be_truthy } - it 'should send email to email owner' do + it 'sends email to email owner' do expect{ notification.new_email(email) }.to change{ ActionMailer::Base.deliveries.size }.by(1) end end @@ -309,7 +331,7 @@ describe NotificationService, services: true do describe '#new_note' do it "records sent notifications" do # Ensure create SentNotification by noteable = merge_request 6 times, not noteable = note - expect(SentNotification).to receive(:record_note).with(note, any_args).exactly(4).times.and_call_original + expect(SentNotification).to receive(:record_note).with(note, any_args).exactly(3).times.and_call_original notification.new_note(note) @@ -357,6 +379,7 @@ describe NotificationService, services: true do it "emails subscribers of the issue's labels" do subscriber = create(:user) label = create(:label, issues: [issue]) + issue.reload label.toggle_subscription(subscriber) notification.new_issue(issue, @u_disabled) @@ -377,6 +400,7 @@ describe NotificationService, services: true do project.team << [guest, :guest] label = create(:label, issues: [confidential_issue]) + confidential_issue.reload label.toggle_subscription(non_member) label.toggle_subscription(author) label.toggle_subscription(assignee) @@ -399,6 +423,13 @@ describe NotificationService, services: true do end end + describe '#new_mentions_in_issue' do + let(:notification_method) { :new_mentions_in_issue } + let(:mentionable) { issue } + + include_examples 'notifications for new mentions' + end + describe '#reassigned_issue' do before do update_custom_notification(:reassign_issue, @u_guest_custom, project) @@ -593,7 +624,7 @@ describe NotificationService, services: true do update_custom_notification(:close_issue, @u_custom_global) end - it 'should sent email to issue assignee and issue author' do + it 'sends email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) should_email(issue.assignee) @@ -646,7 +677,7 @@ describe NotificationService, services: true do update_custom_notification(:reopen_issue, @u_custom_global) end - it 'should send email to issue assignee and issue author' do + it 'sends email to issue assignee and issue author' do notification.reopen_issue(issue, @u_disabled) should_email(issue.assignee) @@ -700,6 +731,8 @@ describe NotificationService, services: true do before do build_team(merge_request.target_project) add_users_with_subscription(merge_request.target_project, merge_request) + update_custom_notification(:new_merge_request, @u_guest_custom, project) + update_custom_notification(:new_merge_request, @u_custom_global) ActionMailer::Base.deliveries.clear end @@ -763,6 +796,13 @@ describe NotificationService, services: true do end end + describe '#new_mentions_in_merge_request' do + let(:notification_method) { :new_mentions_in_merge_request } + let(:mentionable) { merge_request } + + include_examples 'notifications for new mentions' + end + describe '#reassigned_merge_request' do before do update_custom_notification(:reassign_merge_request, @u_guest_custom, project) @@ -922,6 +962,20 @@ describe NotificationService, services: true do should_not_email(@u_lazy_participant) end + it "notifies the merger when merge_when_build_succeeds is true" do + merge_request.merge_when_build_succeeds = true + notification.merge_mr(merge_request, @u_watcher) + + should_email(@u_watcher) + end + + it "does not notify the merger when merge_when_build_succeeds is false" do + merge_request.merge_when_build_succeeds = false + notification.merge_mr(merge_request, @u_watcher) + + should_not_email(@u_watcher) + end + context 'participating' do context 'by assignee' do before do @@ -1004,6 +1058,52 @@ describe NotificationService, services: true do end end end + + describe "#resolve_all_discussions" do + it do + notification.resolve_all_discussions(merge_request, @u_disabled) + + should_email(merge_request.assignee) + should_email(@u_watcher) + should_email(@u_participant_mentioned) + should_email(@subscriber) + should_email(@watcher_and_subscriber) + should_email(@u_guest_watcher) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.resolve_all_discussions(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.resolve_all_discussions(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.resolve_all_discussions(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + end + end end describe 'Projects' do @@ -1029,6 +1129,101 @@ describe NotificationService, services: true do end end + describe 'GroupMember' do + describe '#decline_group_invite' do + let(:creator) { create(:user) } + let(:group) { create(:group) } + let(:member) { create(:user) } + + before(:each) do + group.add_owner(creator) + group.add_developer(member, creator) + end + + it do + group_member = group.members.first + + expect do + notification.decline_group_invite(group_member) + end.to change { ActionMailer::Base.deliveries.size }.by(1) + end + end + end + + describe 'ProjectMember' do + describe '#decline_group_invite' do + let(:project) { create(:project) } + let(:member) { create(:user) } + + before(:each) do + project.team << [member, :developer, project.owner] + end + + it do + project_member = project.members.first + + expect do + notification.decline_project_invite(project_member) + end.to change { ActionMailer::Base.deliveries.size }.by(1) + end + end + end + + context 'guest user in private project' do + let(:private_project) { create(:empty_project, :private) } + let(:guest) { create(:user) } + let(:developer) { create(:user) } + let(:assignee) { create(:user) } + let(:merge_request) { create(:merge_request, source_project: private_project, assignee: assignee) } + let(:merge_request1) { create(:merge_request, source_project: private_project, assignee: assignee, description: "cc @#{guest.username}") } + let(:note) { create(:note, noteable: merge_request, project: private_project) } + + before do + private_project.team << [assignee, :developer] + private_project.team << [developer, :developer] + private_project.team << [guest, :guest] + + ActionMailer::Base.deliveries.clear + end + + it 'filters out guests when new note is created' do + expect(SentNotification).to receive(:record).with(merge_request, any_args).exactly(1).times + + notification.new_note(note) + + should_not_email(guest) + should_email(assignee) + end + + it 'filters out guests when new merge request is created' do + notification.new_merge_request(merge_request1, @u_disabled) + + should_not_email(guest) + should_email(assignee) + end + + it 'filters out guests when merge request is closed' do + notification.close_mr(merge_request, developer) + + should_not_email(guest) + should_email(assignee) + end + + it 'filters out guests when merge request is reopened' do + notification.reopen_mr(merge_request, developer) + + should_not_email(guest) + should_email(assignee) + end + + it 'filters out guests when merge request is merged' do + notification.merge_mr(merge_request, developer) + + should_not_email(guest) + should_email(assignee) + end + end + def build_team(project) @u_watcher = create_global_setting_for(create(:user), :watch) @u_participating = create_global_setting_for(create(:user), :participating) diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 0971fec2e9f..7916c2d957c 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -13,7 +13,7 @@ describe Projects::AutocompleteService, services: true do let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } - it 'should not list project confidential issues for guests' do + it 'does not list project confidential issues for guests' do autocomplete = described_class.new(project, nil) issues = autocomplete.issues.map(&:iid) @@ -23,7 +23,7 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 1 end - it 'should not list project confidential issues for non project members' do + it 'does not list project confidential issues for non project members' do autocomplete = described_class.new(project, non_member) issues = autocomplete.issues.map(&:iid) @@ -33,7 +33,7 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 1 end - it 'should not list project confidential issues for project members with guest role' do + it 'does not list project confidential issues for project members with guest role' do project.team << [member, :guest] autocomplete = described_class.new(project, non_member) @@ -45,7 +45,7 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 1 end - it 'should list project confidential issues for author' do + it 'lists project confidential issues for author' do autocomplete = described_class.new(project, author) issues = autocomplete.issues.map(&:iid) @@ -55,7 +55,7 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 2 end - it 'should list project confidential issues for assignee' do + it 'lists project confidential issues for assignee' do autocomplete = described_class.new(project, assignee) issues = autocomplete.issues.map(&:iid) @@ -65,7 +65,7 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 2 end - it 'should list project confidential issues for project members' do + it 'lists project confidential issues for project members' do project.team << [member, :developer] autocomplete = described_class.new(project, member) @@ -77,7 +77,7 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 3 end - it 'should list all project issues for admin' do + it 'lists all project issues for admin' do autocomplete = described_class.new(project, admin) issues = autocomplete.issues.map(&:iid) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index fd114359467..3ea1273abc3 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -69,7 +69,7 @@ describe Projects::CreateService, services: true do context 'wiki_enabled false does not create wiki repository directory' do before do - @opts.merge!(wiki_enabled: false) + @opts.merge!( { project_feature_attributes: { wiki_access_level: ProjectFeature::DISABLED } }) @project = create_project(@user, @opts) @path = ProjectWiki.new(@project, @user).send(:path_to_repo) end @@ -85,7 +85,7 @@ describe Projects::CreateService, services: true do context 'global builds_enabled false does not enable CI by default' do before do - @opts.merge!(builds_enabled: false) + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) end it { is_expected.to be_falsey } @@ -93,7 +93,7 @@ describe Projects::CreateService, services: true do context 'global builds_enabled true does enable CI by default' do before do - @opts.merge!(builds_enabled: true) + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) end it { is_expected.to be_truthy } @@ -109,7 +109,7 @@ describe Projects::CreateService, services: true do ) end - it 'should not allow a restricted visibility level for non-admins' do + it 'does not allow a restricted visibility level for non-admins' do project = create_project(@user, @opts) expect(project).to respond_to(:errors) expect(project.errors.messages).to have_key(:visibility_level) @@ -118,7 +118,7 @@ describe Projects::CreateService, services: true do ) end - it 'should allow a restricted visibility level for admins' do + it 'allows a restricted visibility level for admins' do admin = create(:admin) project = create_project(admin, @opts) @@ -128,7 +128,7 @@ describe Projects::CreateService, services: true do end context 'repository creation' do - it 'should synchronously create the repository' do + it 'synchronously creates the repository' do expect_any_instance_of(Project).to receive(:create_repository) project = create_project(@user, @opts) diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 29341c5e57e..7dcd03496bb 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -5,6 +5,7 @@ describe Projects::DestroyService, services: true do let!(:project) { create(:project, namespace: user.namespace) } let!(:path) { project.repository.path_to_repo } let!(:remove_path) { path.sub(/\.git\Z/, "+#{project.id}+deleted.git") } + let!(:async) { false } # execute or async_execute context 'Sidekiq inline' do before do @@ -28,6 +29,22 @@ describe Projects::DestroyService, services: true do it { expect(Dir.exist?(remove_path)).to be_truthy } end + context 'async delete of project with private issue visibility' do + let!(:async) { true } + + before do + project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE) + # Run sidekiq immediately to check that renamed repository will be removed + Sidekiq::Testing.inline! { destroy_project(project, user, {}) } + end + + it 'deletes the project' do + expect(Project.all).not_to include(project) + expect(Dir.exist?(path)).to be_falsey + expect(Dir.exist?(remove_path)).to be_falsey + end + end + context 'container registry' do before do stub_container_registry_config(enabled: true) @@ -52,6 +69,10 @@ describe Projects::DestroyService, services: true do end def destroy_project(project, user, params) - Projects::DestroyService.new(project, user, params).execute + if async + Projects::DestroyService.new(project, user, params).async_execute + else + Projects::DestroyService.new(project, user, params).execute + end end end diff --git a/spec/services/projects/enable_deploy_key_service_spec.rb b/spec/services/projects/enable_deploy_key_service_spec.rb new file mode 100644 index 00000000000..a37510cf159 --- /dev/null +++ b/spec/services/projects/enable_deploy_key_service_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Projects::EnableDeployKeyService, services: true do + let(:deploy_key) { create(:deploy_key, public: true) } + let(:project) { create(:empty_project) } + let(:user) { project.creator} + let!(:params) { { key_id: deploy_key.id } } + + it 'enables the key' do + expect do + service.execute + end.to change { project.deploy_keys.count }.from(0).to(1) + end + + context 'trying to add an unaccessable key' do + let(:another_key) { create(:another_key) } + let!(:params) { { key_id: another_key.id } } + + it 'returns nil if the key cannot be added' do + expect(service.execute).to be nil + end + end + + def service + Projects::EnableDeployKeyService.new(project, user, params) + end +end diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 31bb7120d84..64d15c0523c 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -12,12 +12,26 @@ describe Projects::ForkService, services: true do description: 'wow such project') @to_namespace = create(:namespace) @to_user = create(:user, namespace: @to_namespace) + @from_project.add_user(@to_user, :developer) end context 'fork project' do + context 'when forker is a guest' do + before do + @guest = create(:user) + @from_project.add_user(@guest, :guest) + end + subject { fork_project(@from_project, @guest) } + + it { is_expected.not_to be_persisted } + it { expect(subject.errors[:forked_from_project_id]).to eq(['is forbidden']) } + end + describe "successfully creates project in the user namespace" do let(:to_project) { fork_project(@from_project, @to_user) } + it { expect(to_project).to be_persisted } + it { expect(to_project.errors).to be_empty } it { expect(to_project.owner).to eq(@to_user) } it { expect(to_project.namespace).to eq(@to_user.namespace) } it { expect(to_project.star_count).to be_zero } @@ -26,17 +40,19 @@ describe Projects::ForkService, services: true do end context 'project already exists' do - it "should fail due to validation, not transaction failure" do + it "fails due to validation, not transaction failure" do @existing_project = create(:project, creator_id: @to_user.id, name: @from_project.name, namespace: @to_namespace) @to_project = fork_project(@from_project, @to_user) - expect(@existing_project.persisted?).to be_truthy + expect(@existing_project).to be_persisted + + expect(@to_project).not_to be_persisted expect(@to_project.errors[:name]).to eq(['has already been taken']) expect(@to_project.errors[:path]).to eq(['has already been taken']) end end context 'GitLab CI is enabled' do - it "fork and enable CI for fork" do + it "forks and enables CI for fork" do @from_project.enable_ci @to_project = fork_project(@from_project, @to_user) expect(@to_project.builds_enabled?).to be_truthy @@ -81,30 +97,35 @@ describe Projects::ForkService, services: true do @group = create(:group) @group.add_user(@group_owner, GroupMember::OWNER) @group.add_user(@developer, GroupMember::DEVELOPER) + @project.add_user(@developer, :developer) + @project.add_user(@group_owner, :developer) @opts = { namespace: @group } end context 'fork project for group' do it 'group owner successfully forks project into the group' do to_project = fork_project(@project, @group_owner, @opts) + + expect(to_project).to be_persisted + expect(to_project.errors).to be_empty expect(to_project.owner).to eq(@group) expect(to_project.namespace).to eq(@group) expect(to_project.name).to eq(@project.name) expect(to_project.path).to eq(@project.path) expect(to_project.description).to eq(@project.description) - expect(to_project.star_count).to be_zero + expect(to_project.star_count).to be_zero end end context 'fork project for group when user not owner' do - it 'group developer should fail to fork project into the group' do + it 'group developer fails to fork project into the group' do to_project = fork_project(@project, @developer, @opts) expect(to_project.errors[:namespace]).to eq(['is not valid']) end end context 'project already exists in group' do - it 'should fail due to validation, not transaction failure' do + it 'fails due to validation, not transaction failure' do existing_project = create(:project, name: @project.name, namespace: @group) to_project = fork_project(@project, @group_owner, @opts) diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index ad0d58672b3..cf90b33dfb4 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -4,12 +4,15 @@ describe Projects::HousekeepingService do subject { Projects::HousekeepingService.new(project) } let(:project) { create :project } - describe 'execute' do - before do - project.pushes_since_gc = 3 - project.save! - end + before do + project.reset_pushes_since_gc + end + + after do + project.reset_pushes_since_gc + end + describe '#execute' do it 'enqueues a sidekiq job' do expect(subject).to receive(:try_obtain_lease).and_return(true) expect(GitGarbageCollectWorker).to receive(:perform_async).with(project.id) @@ -32,12 +35,12 @@ describe Projects::HousekeepingService do it 'does not reset pushes_since_gc' do expect do expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken) - end.not_to change { project.pushes_since_gc }.from(3) + end.not_to change { project.pushes_since_gc } end end end - describe 'needed?' do + describe '#needed?' do it 'when the count is low enough' do expect(subject.needed?).to eq(false) end @@ -48,25 +51,11 @@ describe Projects::HousekeepingService do end end - describe 'increment!' do - let(:lease_key) { "project_housekeeping:increment!:#{project.id}" } - + describe '#increment!' do it 'increments the pushes_since_gc counter' do - lease = double(:lease, try_obtain: true) - expect(Gitlab::ExclusiveLease).to receive(:new).with(lease_key, anything).and_return(lease) - expect do subject.increment! end.to change { project.pushes_since_gc }.from(0).to(1) end - - it 'does not increment when no lease can be obtained' do - lease = double(:lease, try_obtain: false) - expect(Gitlab::ExclusiveLease).to receive(:new).with(lease_key, anything).and_return(lease) - - expect do - subject.increment! - end.not_to change { project.pushes_since_gc } - end end end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index d5d4d7c56ef..ed1384798ab 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -108,6 +108,16 @@ describe Projects::ImportService, services: true do expect(result[:status]).to eq :error expect(result[:message]).to eq 'Github: failed to connect API' end + + it 'expires existence cache after error' do + allow_any_instance_of(Project).to receive(:repository_exists?).and_return(true) + + expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).with(project.repository_storage_path, project.path_with_namespace, project.import_url).and_raise(Gitlab::Shell::Error.new('Failed to import the repository')) + expect_any_instance_of(Repository).to receive(:expire_emptiness_caches).and_call_original + expect_any_instance_of(Repository).to receive(:expire_exists_cache).and_call_original + + subject.execute + end end def stub_github_omniauth_provider diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index e8b9e6b9238..e139be19140 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -9,7 +9,7 @@ describe Projects::UpdateService, services: true do @opts = {} end - context 'should be private when updated to private' do + context 'is private when updated to private' do before do @created_private = @project.private? @@ -21,7 +21,7 @@ describe Projects::UpdateService, services: true do it { expect(@project.private?).to be_truthy } end - context 'should be internal when updated to internal' do + context 'is internal when updated to internal' do before do @created_private = @project.private? @@ -33,7 +33,7 @@ describe Projects::UpdateService, services: true do it { expect(@project.internal?).to be_truthy } end - context 'should be public when updated to public' do + context 'is public when updated to public' do before do @created_private = @project.private? @@ -50,7 +50,7 @@ describe Projects::UpdateService, services: true do stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end - context 'should be private when updated to private' do + context 'is private when updated to private' do before do @created_private = @project.private? @@ -62,7 +62,7 @@ describe Projects::UpdateService, services: true do it { expect(@project.private?).to be_truthy } end - context 'should be internal when updated to internal' do + context 'is internal when updated to internal' do before do @created_private = @project.private? @@ -74,7 +74,7 @@ describe Projects::UpdateService, services: true do it { expect(@project.internal?).to be_truthy } end - context 'should be private when updated to public' do + context 'is private when updated to public' do before do @created_private = @project.private? @@ -86,7 +86,7 @@ describe Projects::UpdateService, services: true do it { expect(@project.private?).to be_truthy } end - context 'should be public when updated to public by admin' do + context 'is public when updated to public by admin' do before do @created_private = @project.private? @@ -114,7 +114,7 @@ describe Projects::UpdateService, services: true do @fork_created_internal = forked_project.internal? end - context 'should update forks visibility level when parent set to more restrictive' do + context 'updates forks visibility level when parent set to more restrictive' do before do opts.merge!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) update_project(project, user, opts).inspect @@ -126,7 +126,7 @@ describe Projects::UpdateService, services: true do it { expect(project.forks.first.private?).to be_truthy } end - context 'should not update forks visibility level when parent set to less restrictive' do + context 'does not update forks visibility level when parent set to less restrictive' do before do opts.merge!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) update_project(project, user, opts).inspect diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb new file mode 100644 index 00000000000..7d4eff3b6ef --- /dev/null +++ b/spec/services/protected_branches/create_service_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe ProtectedBranches::CreateService, services: true do + let(:project) { create(:empty_project) } + let(:user) { project.owner } + let(:params) do + { + name: 'master', + merge_access_levels_attributes: [ { access_level: Gitlab::Access::MASTER } ], + push_access_levels_attributes: [ { access_level: Gitlab::Access::MASTER } ] + } + end + + describe '#execute' do + subject(:service) { described_class.new(project, user, params) } + + it 'creates a new protected branch' do + expect { service.execute }.to change(ProtectedBranch, :count).by(1) + expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + end + end +end diff --git a/spec/services/repair_ldap_blocked_user_service_spec.rb b/spec/services/repair_ldap_blocked_user_service_spec.rb index ce7d1455975..87192457298 100644 --- a/spec/services/repair_ldap_blocked_user_service_spec.rb +++ b/spec/services/repair_ldap_blocked_user_service_spec.rb @@ -6,14 +6,14 @@ describe RepairLdapBlockedUserService, services: true do subject(:service) { RepairLdapBlockedUserService.new(user) } describe '#execute' do - it 'change to normal block after destroying last ldap identity' do + it 'changes to normal block after destroying last ldap identity' do identity.destroy service.execute expect(user.reload).not_to be_ldap_blocked end - it 'change to normal block after changing last ldap identity to another provider' do + it 'changes to normal block after changing last ldap identity to another provider' do identity.update_attribute(:provider, 'twitter') service.execute diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 7b3a9a75d7c..bd89c4a7c11 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -16,7 +16,7 @@ describe 'Search::GlobalService', services: true do describe '#execute' do context 'unauthenticated' do - it 'should return public projects only' do + it 'returns public projects only' do context = Search::GlobalService.new(nil, search: "searchable") results = context.execute expect(results.objects('projects')).to match_array [public_project] @@ -24,19 +24,19 @@ describe 'Search::GlobalService', services: true do end context 'authenticated' do - it 'should return public, internal and private projects' do + it 'returns public, internal and private projects' do context = Search::GlobalService.new(user, search: "searchable") results = context.execute expect(results.objects('projects')).to match_array [public_project, found_project, internal_project] end - it 'should return only public & internal projects' do + it 'returns only public & internal projects' do context = Search::GlobalService.new(internal_user, search: "searchable") results = context.execute expect(results.objects('projects')).to match_array [internal_project, public_project] end - it 'namespace name should be searchable' do + it 'namespace name is searchable' do context = Search::GlobalService.new(user, search: found_project.namespace.path) results = context.execute expect(results.objects('projects')).to match_array [found_project] diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb new file mode 100644 index 00000000000..b57e338b782 --- /dev/null +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -0,0 +1,505 @@ +require 'spec_helper' + +describe SlashCommands::InterpretService, services: true do + let(:project) { create(:empty_project, :public) } + let(:developer) { create(:user) } + let(:issue) { create(:issue, project: project) } + let(:milestone) { create(:milestone, project: project, title: '9.10') } + let(:inprogress) { create(:label, project: project, title: 'In Progress') } + let(:bug) { create(:label, project: project, title: 'Bug') } + + before do + project.team << [developer, :developer] + end + + describe '#execute' do + let(:service) { described_class.new(project, developer) } + let(:merge_request) { create(:merge_request, source_project: project) } + + shared_examples 'reopen command' do + it 'returns state_event: "reopen" if content contains /reopen' do + issuable.close! + _, updates = service.execute(content, issuable) + + expect(updates).to eq(state_event: 'reopen') + end + end + + shared_examples 'close command' do + it 'returns state_event: "close" if content contains /close' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(state_event: 'close') + end + end + + shared_examples 'title command' do + it 'populates title: "A brand new title" if content contains /title A brand new title' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(title: 'A brand new title') + end + end + + shared_examples 'assign command' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(assignee_id: developer.id) + end + end + + shared_examples 'unassign command' do + it 'populates assignee_id: nil if content contains /unassign' do + issuable.update(assignee_id: developer.id) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(assignee_id: nil) + end + end + + shared_examples 'milestone command' do + it 'fetches milestone and populates milestone_id if content contains /milestone' do + milestone # populate the milestone + _, updates = service.execute(content, issuable) + + expect(updates).to eq(milestone_id: milestone.id) + end + end + + shared_examples 'remove_milestone command' do + it 'populates milestone_id: nil if content contains /remove_milestone' do + issuable.update(milestone_id: milestone.id) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(milestone_id: nil) + end + end + + shared_examples 'label command' do + it 'fetches label ids and populates add_label_ids if content contains /label' do + bug # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [bug.id, inprogress.id]) + end + end + + shared_examples 'multiple label command' do + it 'fetches label ids and populates add_label_ids if content contains multiple /label' do + bug # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [inprogress.id, bug.id]) + end + end + + shared_examples 'multiple label with same argument' do + it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [inprogress.id]) + end + end + + shared_examples 'unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do + issuable.update(label_ids: [inprogress.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(remove_label_ids: [inprogress.id]) + end + end + + shared_examples 'multiple unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do + issuable.update(label_ids: [inprogress.id, bug.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(remove_label_ids: [inprogress.id, bug.id]) + end + end + + shared_examples 'unlabel command with no argument' do + it 'populates label_ids: [] if content contains /unlabel with no arguments' do + issuable.update(label_ids: [inprogress.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: []) + end + end + + shared_examples 'relabel command' do + it 'populates label_ids: [] if content contains /relabel' do + issuable.update(label_ids: [bug.id]) # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: [inprogress.id]) + end + end + + shared_examples 'todo command' do + it 'populates todo_event: "add" if content contains /todo' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(todo_event: 'add') + end + end + + shared_examples 'done command' do + it 'populates todo_event: "done" if content contains /done' do + TodoService.new.mark_todo(issuable, developer) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(todo_event: 'done') + end + end + + shared_examples 'subscribe command' do + it 'populates subscription_event: "subscribe" if content contains /subscribe' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(subscription_event: 'subscribe') + end + end + + shared_examples 'unsubscribe command' do + it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do + issuable.subscribe(developer) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(subscription_event: 'unsubscribe') + end + end + + shared_examples 'due command' do + it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) + end + end + + shared_examples 'remove_due_date command' do + it 'populates due_date: nil if content contains /remove_due_date' do + issuable.update(due_date: Date.today) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(due_date: nil) + end + end + + shared_examples 'wip command' do + it 'returns wip_event: "wip" if content contains /wip' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(wip_event: 'wip') + end + end + + shared_examples 'unwip command' do + it 'returns wip_event: "unwip" if content contains /wip' do + issuable.update(title: issuable.wip_title) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(wip_event: 'unwip') + end + end + + shared_examples 'empty command' do + it 'populates {} if content contains an unsupported command' do + _, updates = service.execute(content, issuable) + + expect(updates).to be_empty + end + end + + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { issue } + end + + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { merge_request } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { issue } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { merge_request } + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { issue } + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/title' } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/assign @abcd1234' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/assign' } + let(:issuable) { issue } + end + + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end + + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { merge_request } + end + + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } + end + + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { merge_request } + end + + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { merge_request } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { merge_request } + end + + it_behaves_like 'multiple label command' do + let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'multiple label with same argument' do + let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'multiple unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } + let(:issuable) { merge_request } + end + + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { issue } + end + + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { merge_request } + end + + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { issue } + end + + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { merge_request } + end + + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { issue } + end + + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { issue } + end + + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'due command' do + let(:content) { '/due 2016-08-28' } + let(:issuable) { issue } + end + + it_behaves_like 'due command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + let(:expected_date) { Date.tomorrow } + end + + it_behaves_like 'due command' do + let(:content) { '/due 5 days from now' } + let(:issuable) { issue } + let(:expected_date) { 5.days.from_now.to_date } + end + + it_behaves_like 'due command' do + let(:content) { '/due in 2 days' } + let(:issuable) { issue } + let(:expected_date) { 2.days.from_now.to_date } + end + + it_behaves_like 'empty command' do + let(:content) { '/due foo bar' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due 2016-08-28' } + let(:issuable) { merge_request } + end + + it_behaves_like 'remove_due_date command' do + let(:content) { '/remove_due_date' } + let(:issuable) { issue } + end + + it_behaves_like 'wip command' do + let(:content) { '/wip' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unwip command' do + let(:content) { '/wip' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { merge_request } + end + + context 'when current_user cannot :admin_issue' do + let(:visitor) { create(:user) } + let(:issue) { create(:issue, project: project, author: visitor) } + let(:service) { described_class.new(project, visitor) } + + it_behaves_like 'empty command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { issue } + end + end + end +end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 43693441450..b4ba28dfe8e 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -40,6 +40,12 @@ describe SystemNoteService, services: true do describe 'note body' do let(:note_lines) { subject.note.split("\n").reject(&:blank?) } + describe 'comparison diff link line' do + it 'adds the comparison text' do + expect(note_lines[2]).to match "[Compare with previous version]" + end + end + context 'without existing commits' do it 'adds a message header' do expect(note_lines[0]).to eq "Added #{new_commits.size} commits:" @@ -48,7 +54,7 @@ describe SystemNoteService, services: true do it 'adds a message line for each commit' do new_commits.each_with_index do |commit, i| # Skip the header - expect(note_lines[i + 1]).to eq "* #{commit.short_id} - #{commit.title}" + expect(HTMLEntities.new.decode(note_lines[i + 1])).to eq "* #{commit.short_id} - #{commit.title}" end end end @@ -75,7 +81,7 @@ describe SystemNoteService, services: true do end it 'includes a commit count' do - expect(summary_line).to end_with " - 2 commits from branch `feature`" + expect(summary_line).to end_with " - 26 commits from branch `feature`" end end @@ -85,7 +91,7 @@ describe SystemNoteService, services: true do end it 'includes a commit count' do - expect(summary_line).to end_with " - 2 commits from branch `feature`" + expect(summary_line).to end_with " - 26 commits from branch `feature`" end end @@ -330,13 +336,13 @@ describe SystemNoteService, services: true do let(:mentioner) { project2.repository.commit } it 'references the mentioning commit' do - expect(subject.note).to eq "mentioned in commit #{mentioner.to_reference(project)}" + expect(subject.note).to eq "Mentioned in commit #{mentioner.to_reference(project)}" end end context 'from non-Commit' do it 'references the mentioning object' do - expect(subject.note).to eq "mentioned in issue #{mentioner.to_reference(project)}" + expect(subject.note).to eq "Mentioned in issue #{mentioner.to_reference(project)}" end end end @@ -346,13 +352,13 @@ describe SystemNoteService, services: true do let(:mentioner) { project.repository.commit } it 'references the mentioning commit' do - expect(subject.note).to eq "mentioned in commit #{mentioner.to_reference}" + expect(subject.note).to eq "Mentioned in commit #{mentioner.to_reference}" end end context 'from non-Commit' do it 'references the mentioning object' do - expect(subject.note).to eq "mentioned in issue #{mentioner.to_reference}" + expect(subject.note).to eq "Mentioned in issue #{mentioner.to_reference}" end end end @@ -362,7 +368,7 @@ describe SystemNoteService, services: true do describe '.cross_reference?' do it 'is truthy when text begins with expected text' do - expect(described_class.cross_reference?('mentioned in something')).to be_truthy + expect(described_class.cross_reference?('Mentioned in something')).to be_truthy end it 'is falsey when text does not begin with expected text' do @@ -445,7 +451,7 @@ describe SystemNoteService, services: true do end context 'commit with cross-reference from fork' do - let(:author2) { create(:user) } + let(:author2) { create(:project_member, :reporter, user: create(:user), project: project).user } let(:forked_project) { Projects::ForkService.new(project, author2).execute } let(:commit2) { forked_project.commit } @@ -471,15 +477,15 @@ describe SystemNoteService, services: true do shared_examples 'cross project mentionable' do include GitlabMarkdownHelper - it 'should contain cross reference to new noteable' do + it 'contains cross reference to new noteable' do expect(subject.note).to include cross_project_reference(new_project, new_noteable) end - it 'should mention referenced noteable' do + it 'mentions referenced noteable' do expect(subject.note).to include new_noteable.to_reference end - it 'should mention referenced project' do + it 'mentions referenced project' do expect(subject.note).to include new_project.to_reference end end @@ -489,7 +495,7 @@ describe SystemNoteService, services: true do it_behaves_like 'cross project mentionable' - it 'should notify about noteable being moved to' do + it 'notifies about noteable being moved to' do expect(subject.note).to match /Moved to/ end end @@ -499,7 +505,7 @@ describe SystemNoteService, services: true do it_behaves_like 'cross project mentionable' - it 'should notify about noteable being moved from' do + it 'notifies about noteable being moved from' do expect(subject.note).to match /Moved from/ end end @@ -507,7 +513,7 @@ describe SystemNoteService, services: true do context 'invalid direction' do let(:direction) { :invalid } - it 'should raise error' do + it 'raises error' do expect { subject }.to raise_error StandardError, /Invalid direction/ end end @@ -525,13 +531,13 @@ describe SystemNoteService, services: true do include JiraServiceHelper describe 'JIRA integration' do - let(:project) { create(:project) } + let(:project) { create(:jira_project) } let(:author) { create(:user) } let(:issue) { create(:issue, project: project) } let(:mergereq) { create(:merge_request, :simple, target_project: project, source_project: project) } let(:jira_issue) { ExternalIssue.new("JIRA-1", project)} - let(:jira_tracker) { project.create_jira_service if project.jira_service.nil? } - let(:commit) { project.commit } + let(:jira_tracker) { project.jira_service } + let(:commit) { project.repository.commits('master').find { |commit| commit.id == '5937ac0a7beb003549fc5fd26fc247adbce4a52e' } } context 'in JIRA issue tracker' do before do @@ -539,10 +545,6 @@ describe SystemNoteService, services: true do WebMock.stub_request(:post, jira_api_comment_url) end - after do - jira_tracker.destroy! - end - describe "new reference" do before do WebMock.stub_request(:get, jira_api_comment_url).to_return(body: jira_issue_comments) @@ -555,7 +557,7 @@ describe SystemNoteService, services: true do describe "existing reference" do before do - message = %Q{[#{author.name}|http://localhost/u/#{author.username}] mentioned this issue in [a commit of #{project.path_with_namespace}|http://localhost/#{project.path_with_namespace}/commit/#{commit.id}]:\\n'#{commit.title}'} + message = %Q{[#{author.name}|http://localhost/#{author.username}] mentioned this issue in [a commit of #{project.path_with_namespace}|http://localhost/#{project.path_with_namespace}/commit/#{commit.id}]:\\n'#{commit.title}'} WebMock.stub_request(:get, jira_api_comment_url).to_return(body: %Q({"comments":[{"body":"#{message}"}]})) end @@ -572,10 +574,6 @@ describe SystemNoteService, services: true do WebMock.stub_request(:get, jira_api_comment_url).to_return(body: jira_issue_comments) end - after do - jira_tracker.destroy! - end - subject { described_class.cross_reference(jira_issue, issue, author) } it { is_expected.to eq(jira_status_message) } diff --git a/spec/services/test_hook_service_spec.rb b/spec/services/test_hook_service_spec.rb index 4f47e89b4b5..4f6dd8c6d3f 100644 --- a/spec/services/test_hook_service_spec.rb +++ b/spec/services/test_hook_service_spec.rb @@ -6,7 +6,7 @@ describe TestHookService, services: true do let(:hook) { create :project_hook, project: project } describe '#execute' do - it "should execute successfully" do + it "executes successfully" do stub_request(:post, hook.url).to_return(status: 200) expect(TestHookService.new.execute(hook, user)).to be_truthy end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 34d8ea9090e..ed55791d24e 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -145,6 +145,14 @@ describe TodoService, services: true do end end + describe '#destroy_issue' do + it 'refresh the todos count cache for the user' do + expect(john_doe).to receive(:update_todos_count_cache).and_call_original + + service.destroy_issue(issue, john_doe) + end + end + describe '#reassigned_issue' do it 'creates a pending todo for new assignee' do unassigned_issue.update_attribute(:assignee, john_doe) @@ -194,12 +202,12 @@ describe TodoService, services: true do end end - describe '#mark_todos_as_done' do - it 'marks related todos for the user as done' do - first_todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) - second_todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) + shared_examples 'marking todos as done' do |meth| + let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } + let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } - service.mark_todos_as_done([first_todo, second_todo], john_doe) + it 'marks related todos for the user as done' do + service.send(meth, collection, john_doe) expect(first_todo.reload).to be_done expect(second_todo.reload).to be_done @@ -207,20 +215,30 @@ describe TodoService, services: true do describe 'cached counts' do it 'updates when todos change' do - todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) - expect(john_doe.todos_done_count).to eq(0) - expect(john_doe.todos_pending_count).to eq(1) + expect(john_doe.todos_pending_count).to eq(2) expect(john_doe).to receive(:update_todos_count_cache).and_call_original - service.mark_todos_as_done([todo], john_doe) + service.send(meth, collection, john_doe) - expect(john_doe.todos_done_count).to eq(1) + expect(john_doe.todos_done_count).to eq(2) expect(john_doe.todos_pending_count).to eq(0) end end end + describe '#mark_todos_as_done' do + it_behaves_like 'marking todos as done', :mark_todos_as_done do + let(:collection) { [first_todo, second_todo] } + end + end + + describe '#mark_todos_as_done_by_ids' do + it_behaves_like 'marking todos as done', :mark_todos_as_done_by_ids do + let(:collection) { [first_todo, second_todo].map(&:id) } + end + end + describe '#new_note' do let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } @@ -290,6 +308,18 @@ describe TodoService, services: true do should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) end end + + describe '#todo_exists?' do + it 'returns false when no todo exist for the given issuable' do + expect(service.todo_exist?(unassigned_issue, author)).to be_falsy + end + + it 'returns true when a todo exist for the given issuable' do + service.mark_todo(unassigned_issue, author) + + expect(service.todo_exist?(unassigned_issue, author)).to be_truthy + end + end end describe 'Merge Requests' do @@ -315,7 +345,7 @@ describe TodoService, services: true do service.new_merge_request(mr_assigned, author) should_create_todo(user: member, target: mr_assigned, action: Todo::MENTIONED) - should_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) + should_not_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) should_create_todo(user: author, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: john_doe, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: mr_assigned, action: Todo::MENTIONED) @@ -327,7 +357,7 @@ describe TodoService, services: true do service.update_merge_request(mr_assigned, author) should_create_todo(user: member, target: mr_assigned, action: Todo::MENTIONED) - should_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) + should_not_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) should_create_todo(user: john_doe, target: mr_assigned, action: Todo::MENTIONED) should_create_todo(user: author, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: mr_assigned, action: Todo::MENTIONED) @@ -351,6 +381,7 @@ describe TodoService, services: true do should_not_create_todo(user: john_doe, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: member, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: mr_assigned, action: Todo::MENTIONED) + should_not_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) end it 'does not raise an error when description not change' do @@ -372,6 +403,14 @@ describe TodoService, services: true do end end + describe '#destroy_merge_request' do + it 'refresh the todos count cache for the user' do + expect(john_doe).to receive(:update_todos_count_cache).and_call_original + + service.destroy_merge_request(mr_assigned, john_doe) + end + end + describe '#reassigned_merge_request' do it 'creates a pending todo for new assignee' do mr_unassigned.update_attribute(:assignee, john_doe) @@ -392,6 +431,11 @@ describe TodoService, services: true do should_create_todo(user: john_doe, target: mr_assigned, author: john_doe, action: Todo::ASSIGNED) end + + it 'does not create a todo for guests' do + service.reassigned_merge_request(mr_assigned, author) + should_not_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) + end end describe '#merge_merge_request' do @@ -403,6 +447,11 @@ describe TodoService, services: true do expect(first_todo.reload).to be_done expect(second_todo.reload).to be_done end + + it 'does not create todo for guests' do + service.merge_merge_request(mr_assigned, john_doe) + should_not_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) + end end describe '#new_award_emoji' do @@ -457,6 +506,13 @@ describe TodoService, services: true do should_create_todo(user: john_doe, target: mr_unassigned, author: author, action: Todo::MENTIONED, note: legacy_diff_note_on_merge_request) end + + it 'does not create todo for guests' do + note_on_merge_request = create :note_on_merge_request, project: project, noteable: mr_assigned, note: mentions + service.new_note(note_on_merge_request, author) + + should_not_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) + end end end @@ -472,6 +528,63 @@ describe TodoService, services: true do expect(john_doe.todos_pending_count).to eq(1) end + describe '#mark_todos_as_done' do + let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) } + let(:another_issue) { create(:issue, project: project, author: author, assignee: john_doe) } + + it 'marks a relation of todos as done' do + create(:todo, :mentioned, user: john_doe, target: issue, project: project) + + todos = TodosFinder.new(john_doe, {}).execute + expect { TodoService.new.mark_todos_as_done(todos, john_doe) } + .to change { john_doe.todos.done.count }.from(0).to(1) + end + + it 'marks an array of todos as done' do + todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) + + expect { TodoService.new.mark_todos_as_done([todo], john_doe) } + .to change { todo.reload.state }.from('pending').to('done') + end + + it 'returns the number of updated todos' do # Needed on API + todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) + + expect(TodoService.new.mark_todos_as_done([todo], john_doe)).to eq(1) + end + + context 'when some of the todos are done already' do + before do + create(:todo, :mentioned, user: john_doe, target: issue, project: project) + create(:todo, :mentioned, user: john_doe, target: another_issue, project: project) + end + + it 'returns the number of those still pending' do + TodoService.new.mark_pending_todos_as_done(issue, john_doe) + + expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq(1) + end + + it 'returns 0 if all are done' do + TodoService.new.mark_pending_todos_as_done(issue, john_doe) + TodoService.new.mark_pending_todos_as_done(another_issue, john_doe) + + expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq(0) + end + end + + it 'caches the number of todos of a user', :caching do + create(:todo, :mentioned, user: john_doe, target: issue, project: project) + todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) + TodoService.new.mark_todos_as_done([todo], john_doe) + + expect_any_instance_of(TodosFinder).not_to receive(:execute) + + expect(john_doe.todos_done_count).to eq(1) + expect(john_doe.todos_pending_count).to eq(1) + end + end + def should_create_todo(attributes = {}) attributes.reverse_merge!( project: project, diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb index 6f8f7109e14..b507d38f472 100644 --- a/spec/simplecov_env.rb +++ b/spec/simplecov_env.rb @@ -1,4 +1,5 @@ require 'simplecov' +require 'active_support/core_ext/numeric/time' module SimpleCovEnv extend self @@ -48,7 +49,7 @@ module SimpleCovEnv add_group 'Uploaders', 'app/uploaders' add_group 'Validators', 'app/validators' - merge_timeout 7200 + merge_timeout 365.days end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4f3aacf55be..b19f5824236 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -26,13 +26,14 @@ RSpec.configure do |config| config.verbose_retry = true config.display_try_failure_messages = true - config.include Devise::TestHelpers, type: :controller - config.include LoginHelpers, type: :feature - config.include LoginHelpers, type: :request + config.include Devise::Test::ControllerHelpers, type: :controller + config.include Warden::Test::Helpers, type: :request + config.include LoginHelpers, type: :feature config.include StubConfiguration config.include EmailHelpers config.include TestEnv config.include ActiveJob::TestHelper + config.include ActiveSupport::Testing::TimeHelpers config.include StubGitlabCalls config.include StubGitlabData @@ -42,6 +43,13 @@ RSpec.configure do |config| config.before(:suite) do TestEnv.init end + + config.around(:each, :caching) do |example| + caching_store = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new if example.metadata[:caching] + example.run + Rails.cache = caching_store + end end FactoryGirl::SyntaxRunner.class_eval do diff --git a/spec/support/api/members_shared_examples.rb b/spec/support/api/members_shared_examples.rb new file mode 100644 index 00000000000..dab71a35a55 --- /dev/null +++ b/spec/support/api/members_shared_examples.rb @@ -0,0 +1,11 @@ +shared_examples 'a 404 response when source is private' do + before do + source.update_column(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) + end + + it 'returns 404' do + route + + expect(response).to have_http_status(404) + end +end diff --git a/spec/support/api/schema_matcher.rb b/spec/support/api/schema_matcher.rb new file mode 100644 index 00000000000..e42d727672b --- /dev/null +++ b/spec/support/api/schema_matcher.rb @@ -0,0 +1,8 @@ +RSpec::Matchers.define :match_response_schema do |schema, **options| + match do |response| + schema_directory = "#{Dir.pwd}/spec/fixtures/api/schemas" + schema_path = "#{schema_directory}/#{schema}.json" + + JSON::Validator.validate!(schema_path, response.body, options) + end +end diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb new file mode 100644 index 00000000000..62a5b46d47b --- /dev/null +++ b/spec/support/cycle_analytics_helpers.rb @@ -0,0 +1,68 @@ +module CycleAnalyticsHelpers + def create_commit_referencing_issue(issue, branch_name: random_git_name) + project.repository.add_branch(user, branch_name, 'master') + create_commit("Commit for ##{issue.iid}", issue.project, user, branch_name) + end + + def create_commit(message, project, user, branch_name, count: 1) + oldrev = project.repository.commit(branch_name).sha + commit_shas = Array.new(count) do |index| + filename = random_git_name + + options = { + committer: project.repository.user_to_committer(user), + author: project.repository.user_to_committer(user), + commit: { message: message, branch: branch_name, update_ref: true }, + file: { content: "content", path: filename, update: false } + } + + commit_sha = Gitlab::Git::Blob.commit(project.repository, options) + project.repository.commit(commit_sha) + + commit_sha + end + + GitPushService.new(project, + user, + oldrev: oldrev, + newrev: commit_shas.last, + ref: 'refs/heads/master').execute + end + + def create_merge_request_closing_issue(issue, message: nil, source_branch: nil) + if !source_branch || project.repository.commit(source_branch).blank? + source_branch = random_git_name + project.repository.add_branch(user, source_branch, 'master') + end + + sha = project.repository.commit_file(user, random_git_name, "content", "commit message", source_branch, false) + project.repository.commit(sha) + + opts = { + title: 'Awesome merge_request', + description: message || "Fixes #{issue.to_reference}", + source_branch: source_branch, + target_branch: 'master' + } + + MergeRequests::CreateService.new(project, user, opts).execute + end + + def merge_merge_requests_closing_issue(issue) + merge_requests = issue.closed_by_merge_requests + merge_requests.each { |merge_request| MergeRequests::MergeService.new(project, user).execute(merge_request) } + end + + def deploy_master(environment: 'production') + CreateDeploymentService.new(project, user, { + environment: environment, + ref: 'master', + tag: false, + sha: project.repository.commit('master').sha + }).execute + end +end + +RSpec.configure do |config| + config.include CycleAnalyticsHelpers +end diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb new file mode 100644 index 00000000000..8e19a6c92e2 --- /dev/null +++ b/spec/support/cycle_analytics_helpers/test_generation.rb @@ -0,0 +1,161 @@ +# rubocop:disable Metrics/AbcSize + +# Note: The ABC size is large here because we have a method generating test cases with +# multiple nested contexts. This shouldn't count as a violation. + +module CycleAnalyticsHelpers + module TestGeneration + # Generate the most common set of specs that all cycle analytics phases need to have. + # + # Arguments: + # + # phase: Which phase are we testing? Will call `CycleAnalytics.new.send(phase)` for the final assertion + # data_fn: A function that returns a hash, constituting initial data for the test case + # start_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with + # `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`). + # Each `condition_fn` is expected to implement a case which consitutes the start of the given cycle analytics phase. + # end_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with + # `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`). + # Each `condition_fn` is expected to implement a case which consitutes the end of the given cycle analytics phase. + # before_end_fn: This function is run before calling the end time conditions. Used for setup that needs to be run between the start and end conditions. + # post_fn: Code that needs to be run after running the end time conditions. + + def generate_cycle_analytics_spec(phase:, data_fn:, start_time_conditions:, end_time_conditions:, before_end_fn: nil, post_fn: nil) + combinations_of_start_time_conditions = (1..start_time_conditions.size).flat_map { |size| start_time_conditions.combination(size).to_a } + combinations_of_end_time_conditions = (1..end_time_conditions.size).flat_map { |size| end_time_conditions.combination(size).to_a } + + scenarios = combinations_of_start_time_conditions.product(combinations_of_end_time_conditions) + scenarios.each do |start_time_conditions, end_time_conditions| + context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do + context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do + it "finds the median of available durations between the two conditions" do + time_differences = Array.new(5) do |index| + data = data_fn[self] + start_time = (index * 10).days.from_now + end_time = start_time + rand(1..5).days + + start_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(start_time) { condition_fn[self, data] } + end + + # Run `before_end_fn` at the midpoint between `start_time` and `end_time` + Timecop.freeze(start_time + (end_time - start_time) / 2) { before_end_fn[self, data] } if before_end_fn + + end_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(end_time) { condition_fn[self, data] } + end + + Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn + + end_time - start_time + end + + median_time_difference = time_differences.sort[2] + expect(subject.send(phase)).to be_within(5).of(median_time_difference) + end + + context "when the data belongs to another project" do + let(:other_project) { create(:project) } + + it "returns nil" do + # Use a stub to "trick" the data/condition functions + # into using another project. This saves us from having to + # define separate data/condition functions for this particular + # test case. + allow(self).to receive(:project) { other_project } + + 5.times do + data = data_fn[self] + start_time = Time.now + end_time = rand(1..10).days.from_now + + start_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(start_time) { condition_fn[self, data] } + end + + end_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(end_time) { condition_fn[self, data] } + end + + Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn + end + + # Turn off the stub before checking assertions + allow(self).to receive(:project).and_call_original + + expect(subject.send(phase)).to be_nil + end + end + + context "when the end condition happens before the start condition" do + it 'returns nil' do + data = data_fn[self] + start_time = Time.now + end_time = start_time + rand(1..5).days + + # Run `before_end_fn` at the midpoint between `start_time` and `end_time` + Timecop.freeze(start_time + (end_time - start_time) / 2) { before_end_fn[self, data] } if before_end_fn + + end_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(start_time) { condition_fn[self, data] } + end + + start_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(end_time) { condition_fn[self, data] } + end + + Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn + + expect(subject.send(phase)).to be_nil + end + end + end + end + + context "start condition NOT PRESENT: #{start_time_conditions.map(&:first).to_sentence}" do + context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do + it "returns nil" do + 5.times do + data = data_fn[self] + end_time = rand(1..10).days.from_now + + end_time_conditions.each_with_index do |(condition_name, condition_fn), index| + Timecop.freeze(end_time + index.days) { condition_fn[self, data] } + end + + Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn + end + + expect(subject.send(phase)).to be_nil + end + end + end + + context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do + context "end condition NOT PRESENT: #{end_time_conditions.map(&:first).to_sentence}" do + it "returns nil" do + 5.times do + data = data_fn[self] + start_time = Time.now + + start_time_conditions.each do |condition_name, condition_fn| + Timecop.freeze(start_time) { condition_fn[self, data] } + end + + post_fn[self, data] if post_fn + end + + expect(subject.send(phase)).to be_nil + end + end + end + end + + context "when none of the start / end conditions are matched" do + it "returns nil" do + expect(subject.send(phase)).to be_nil + end + end + end + end +end diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb index e0dbc9aa84c..ac38e31b77e 100644 --- a/spec/support/db_cleaner.rb +++ b/spec/support/db_cleaner.rb @@ -15,7 +15,7 @@ RSpec.configure do |config| DatabaseCleaner.start end - config.after(:each) do + config.append_after(:each) do DatabaseCleaner.clean end end diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb index a85ab22ce36..0bfc4685532 100644 --- a/spec/support/email_helpers.rb +++ b/spec/support/email_helpers.rb @@ -3,6 +3,16 @@ module EmailHelpers ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1 end + def reset_delivered_emails! + ActionMailer::Base.deliveries.clear + end + + def should_only_email(*users) + users.each {|user| should_email(user) } + recipients = ActionMailer::Base.deliveries.flat_map(&:to) + expect(recipients.count).to eq(users.count) + end + def should_email(user) expect(sent_to_user?(user)).to be_truthy end diff --git a/spec/support/fake_u2f_device.rb b/spec/support/fake_u2f_device.rb index f550e9a0160..8c407b867fe 100644 --- a/spec/support/fake_u2f_device.rb +++ b/spec/support/fake_u2f_device.rb @@ -1,6 +1,9 @@ class FakeU2fDevice - def initialize(page) + attr_reader :name + + def initialize(page, name) @page = page + @name = name end def respond_to_u2f_registration diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb new file mode 100644 index 00000000000..5e3b8f2b23e --- /dev/null +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -0,0 +1,261 @@ +# Specifications for behavior common to all objects with executable attributes. +# It takes a `issuable_type`, and expect an `issuable`. + +shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type| + include SlashCommandsHelpers + include WaitForAjax + + let(:master) { create(:user) } + let(:assignee) { create(:user, username: 'bob') } + let(:guest) { create(:user) } + let(:project) { create(:project, :public) } + let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } + let!(:label_bug) { create(:label, project: project, title: 'bug') } + let!(:label_feature) { create(:label, project: project, title: 'feature') } + let(:new_url_opts) { {} } + + before do + project.team << [master, :master] + project.team << [assignee, :developer] + project.team << [guest, :guest] + login_with(master) + end + + after do + # Ensure all outstanding Ajax requests are complete to avoid database deadlocks + wait_for_ajax + end + + describe "new #{issuable_type}" do + context 'with commands in the description' do + it "creates the #{issuable_type} and interpret commands accordingly" do + visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) + fill_in "#{issuable_type}_title", with: 'bug 345' + fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\"" + click_button "Submit #{issuable_type}".humanize + + issuable = project.public_send(issuable_type.to_s.pluralize).first + + expect(issuable.description).to eq "bug description" + expect(issuable.labels).to eq [label_bug] + expect(issuable.milestone).to eq milestone + expect(page).to have_content 'bug 345' + expect(page).to have_content 'bug description' + end + end + end + + describe "note on #{issuable_type}" do + before do + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + context 'with a note containing commands' do + it 'creates a note without the commands and interpret the commands accordingly' do + write_note("Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\"") + + expect(page).to have_content 'Awesome!' + expect(page).not_to have_content '/assign @bob' + expect(page).not_to have_content '/label ~bug' + expect(page).not_to have_content '/milestone %"ASAP"' + + issuable.reload + note = issuable.notes.user.first + + expect(note.note).to eq "Awesome!" + expect(issuable.assignee).to eq assignee + expect(issuable.labels).to eq [label_bug] + expect(issuable.milestone).to eq milestone + end + end + + context 'with a note containing only commands' do + it 'does not create a note but interpret the commands accordingly' do + write_note("/assign @bob\n/label ~bug\n/milestone %\"ASAP\"") + + expect(page).not_to have_content '/assign @bob' + expect(page).not_to have_content '/label ~bug' + expect(page).not_to have_content '/milestone %"ASAP"' + expect(page).to have_content 'Your commands have been executed!' + + issuable.reload + + expect(issuable.notes.user).to be_empty + expect(issuable.assignee).to eq assignee + expect(issuable.labels).to eq [label_bug] + expect(issuable.milestone).to eq milestone + end + end + + context "with a note closing the #{issuable_type}" do + before do + expect(issuable).to be_open + end + + context "when current user can close #{issuable_type}" do + it "closes the #{issuable_type}" do + write_note("/close") + + expect(page).not_to have_content '/close' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.reload).to be_closed + end + end + + context "when current user cannot close #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not close the #{issuable_type}" do + write_note("/close") + + expect(page).not_to have_content '/close' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(issuable).to be_open + end + end + end + + context "with a note reopening the #{issuable_type}" do + before do + issuable.close + expect(issuable).to be_closed + end + + context "when current user can reopen #{issuable_type}" do + it "reopens the #{issuable_type}" do + write_note("/reopen") + + expect(page).not_to have_content '/reopen' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.reload).to be_open + end + end + + context "when current user cannot reopen #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not reopen the #{issuable_type}" do + write_note("/reopen") + + expect(page).not_to have_content '/reopen' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(issuable).to be_closed + end + end + end + + context "with a note changing the #{issuable_type}'s title" do + context "when current user can change title of #{issuable_type}" do + it "reopens the #{issuable_type}" do + write_note("/title Awesome new title") + + expect(page).not_to have_content '/title' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.reload.title).to eq 'Awesome new title' + end + end + + context "when current user cannot change title of #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not reopen the #{issuable_type}" do + write_note("/title Awesome new title") + + expect(page).not_to have_content '/title' + expect(page).not_to have_content 'Your commands have been executed!' + + expect(issuable.reload.title).not_to eq 'Awesome new title' + end + end + end + + context "with a note marking the #{issuable_type} as todo" do + it "creates a new todo for the #{issuable_type}" do + write_note("/todo") + + expect(page).not_to have_content '/todo' + expect(page).to have_content 'Your commands have been executed!' + + todos = TodosFinder.new(master).execute + todo = todos.first + + expect(todos.size).to eq 1 + expect(todo).to be_pending + expect(todo.target).to eq issuable + expect(todo.author).to eq master + expect(todo.user).to eq master + end + end + + context "with a note marking the #{issuable_type} as done" do + before do + TodoService.new.mark_todo(issuable, master) + end + + it "creates a new todo for the #{issuable_type}" do + todos = TodosFinder.new(master).execute + todo = todos.first + + expect(todos.size).to eq 1 + expect(todos.first).to be_pending + expect(todo.target).to eq issuable + expect(todo.author).to eq master + expect(todo.user).to eq master + + write_note("/done") + + expect(page).not_to have_content '/done' + expect(page).to have_content 'Your commands have been executed!' + + expect(todo.reload).to be_done + end + end + + context "with a note subscribing to the #{issuable_type}" do + it "creates a new todo for the #{issuable_type}" do + expect(issuable.subscribed?(master)).to be_falsy + + write_note("/subscribe") + + expect(page).not_to have_content '/subscribe' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.subscribed?(master)).to be_truthy + end + end + + context "with a note unsubscribing to the #{issuable_type} as done" do + before do + issuable.subscribe(master) + end + + it "creates a new todo for the #{issuable_type}" do + expect(issuable.subscribed?(master)).to be_truthy + + write_note("/unsubscribe") + + expect(page).not_to have_content '/unsubscribe' + expect(page).to have_content 'Your commands have been executed!' + + expect(issuable.subscribed?(master)).to be_falsy + end + end + end +end diff --git a/spec/support/git_helpers.rb b/spec/support/git_helpers.rb new file mode 100644 index 00000000000..93422390ef7 --- /dev/null +++ b/spec/support/git_helpers.rb @@ -0,0 +1,9 @@ +module GitHelpers + def random_git_name + "#{FFaker::Product.brand}-#{FFaker::Product.brand}-#{rand(1000)}" + end +end + +RSpec.configure do |config| + config.include GitHelpers +end diff --git a/spec/support/git_http_helpers.rb b/spec/support/git_http_helpers.rb new file mode 100644 index 00000000000..46b686fce94 --- /dev/null +++ b/spec/support/git_http_helpers.rb @@ -0,0 +1,48 @@ +module GitHttpHelpers + def clone_get(project, options = {}) + get "/#{project}/info/refs", { service: 'git-upload-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token)) + end + + def clone_post(project, options = {}) + post "/#{project}/git-upload-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token)) + end + + def push_get(project, options = {}) + get "/#{project}/info/refs", { service: 'git-receive-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token)) + end + + def push_post(project, options = {}) + post "/#{project}/git-receive-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token)) + end + + def download(project, user: nil, password: nil, spnego_request_token: nil) + args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }] + + clone_get(*args) + yield response + + clone_post(*args) + yield response + end + + def upload(project, user: nil, password: nil, spnego_request_token: nil) + args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }] + + push_get(*args) + yield response + + push_post(*args) + yield response + end + + def auth_env(user, password, spnego_request_token) + env = workhorse_internal_api_request_header + if user && password + env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, password) + elsif spnego_request_token + env['HTTP_AUTHORIZATION'] = "Negotiate #{::Base64.strict_encode64('opaque_request_token')}" + end + + env + end +end diff --git a/spec/support/import_export/configuration_helper.rb b/spec/support/import_export/configuration_helper.rb new file mode 100644 index 00000000000..f752508d48c --- /dev/null +++ b/spec/support/import_export/configuration_helper.rb @@ -0,0 +1,29 @@ +module ConfigurationHelper + # Returns a list of models from hashes/arrays contained in +project_tree+ + def names_from_tree(project_tree) + project_tree.map do |branch_or_model| + branch_or_model = branch_or_model.to_s if branch_or_model.is_a?(Symbol) + + branch_or_model.is_a?(String) ? branch_or_model : names_from_tree(branch_or_model) + end + end + + def relation_class_for_name(relation_name) + relation_name = Gitlab::ImportExport::RelationFactory::OVERRIDES[relation_name.to_sym] || relation_name + relation_name.to_s.classify.constantize + end + + def parsed_attributes(relation_name, attributes) + excluded_attributes = config_hash['excluded_attributes'][relation_name] + included_attributes = config_hash['included_attributes'][relation_name] + + attributes = attributes - JSON[excluded_attributes.to_json] if excluded_attributes + attributes = attributes & JSON[included_attributes.to_json] if included_attributes + + attributes + end + + def associations_for(safe_model) + safe_model.reflect_on_all_associations.map { |assoc| assoc.name.to_s } + end +end diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb new file mode 100644 index 00000000000..1b0a4583f5c --- /dev/null +++ b/spec/support/import_export/export_file_helper.rb @@ -0,0 +1,137 @@ +require './spec/support/import_export/configuration_helper' + +module ExportFileHelper + include ConfigurationHelper + + ObjectWithParent = Struct.new(:object, :parent, :key_found) + + def setup_project + project = create(:project, :public) + + create(:release, project: project) + + issue = create(:issue, assignee: user, project: project) + snippet = create(:project_snippet, project: project) + label = create(:label, project: project) + milestone = create(:milestone, project: project) + merge_request = create(:merge_request, source_project: project, milestone: milestone) + commit_status = create(:commit_status, project: project) + + create(:label_link, label: label, target: issue) + + ci_pipeline = create(:ci_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + statuses: [commit_status]) + + create(:ci_build, pipeline: ci_pipeline, project: project) + create(:milestone, project: project) + create(:note, noteable: issue, project: project) + create(:note, noteable: merge_request, project: project) + create(:note, noteable: snippet, project: project) + create(:note_on_commit, + author: user, + project: project, + commit_id: ci_pipeline.sha) + + create(:event, target: milestone, project: project, action: Event::CREATED, author: user) + create(:project_member, :master, user: user, project: project) + create(:ci_variable, project: project) + create(:ci_trigger, project: project) + key = create(:deploy_key) + key.projects << project + create(:service, project: project) + create(:project_hook, project: project, token: 'token') + create(:protected_branch, project: project) + + project + end + + # Expands the compressed file for an exported project into +tmpdir+ + def in_directory_with_expanded_export(project) + Dir.mktmpdir do |tmpdir| + export_file = project.export_project_path + _output, exit_status = Gitlab::Popen.popen(%W{tar -zxf #{export_file} -C #{tmpdir}}) + + yield(exit_status, tmpdir) + end + end + + # Recursively finds key/values including +key+ as part of the key, inside a nested hash + def deep_find_with_parent(sensitive_key_word, object, found = nil) + sensitive_key_found = object_contains_key?(object, sensitive_key_word) + + # Returns the parent object and the object found containing a sensitive word as part of the key + if sensitive_key_found && object[sensitive_key_found] + ObjectWithParent.new(object[sensitive_key_found], object, sensitive_key_found) + elsif object.is_a?(Enumerable) + # Recursively lookup for keys containing sensitive words in a Hash or Array + object_with_parent = nil + + object.find do |*hash_or_array| + object_with_parent = deep_find_with_parent(sensitive_key_word, hash_or_array.last, found) + end + + object_with_parent + end + end + + # Return true if the hash has a key containing a sensitive word + def object_contains_key?(object, sensitive_key_word) + return false unless object.is_a?(Hash) + + object.keys.find { |key| key.include?(sensitive_key_word) } + end + + # Returns the offended ObjectWithParent object if a sensitive word is found inside a hash, + # excluding the whitelisted safe hashes. + def find_sensitive_attributes(sensitive_word, project_hash) + loop do + object_with_parent = deep_find_with_parent(sensitive_word, project_hash) + + return nil unless object_with_parent && object_with_parent.object + + if is_safe_hash?(object_with_parent.parent, sensitive_word) + # It's in the safe list, remove hash and keep looking + object_with_parent.parent.delete(object_with_parent.key_found) + else + return object_with_parent + end + + nil + end + end + + # Returns true if it's one of the excluded models in +safe_list+ + def is_safe_hash?(parent, sensitive_word) + return false unless parent && safe_list[sensitive_word.to_sym] + + # Extra attributes that appear in a model but not in the exported hash. + excluded_attributes = ['type'] + + safe_list[sensitive_word.to_sym].each do |model| + # Check whether this is a hash attribute inside a model + if model.is_a?(Symbol) + return true if (safe_hashes[model] - parent.keys).empty? + else + return true if safe_model?(model, excluded_attributes, parent) + end + end + + false + end + + # Compares model attributes with those those found in the hash + # and returns true if there is a match, ignoring some excluded attributes. + def safe_model?(model, excluded_attributes, parent) + excluded_attributes += associations_for(model) + parsed_model_attributes = parsed_attributes(model.name.underscore, model.attribute_names) + + (parsed_model_attributes - parent.keys - excluded_attributes).empty? + end + + def file_permissions(file) + File.stat(file).mode & 0777 + end +end diff --git a/spec/support/import_export/import_export.yml b/spec/support/import_export/import_export.yml index 3ceec506401..17136dee000 100644 --- a/spec/support/import_export/import_export.yml +++ b/spec/support/import_export/import_export.yml @@ -7,6 +7,8 @@ project_tree: - :merge_request_test - commit_statuses: - :commit + - project_members: + - :user included_attributes: project: @@ -14,6 +16,8 @@ included_attributes: - :path merge_requests: - :id + user: + - :email excluded_attributes: merge_requests: diff --git a/spec/support/ldap_helpers.rb b/spec/support/ldap_helpers.rb new file mode 100644 index 00000000000..079f244475c --- /dev/null +++ b/spec/support/ldap_helpers.rb @@ -0,0 +1,47 @@ +module LdapHelpers + def ldap_adapter(provider = 'ldapmain', ldap = double(:ldap)) + ::Gitlab::LDAP::Adapter.new(provider, ldap) + end + + def user_dn(uid) + "uid=#{uid},ou=users,dc=example,dc=com" + end + + # Accepts a hash of Gitlab::LDAP::Config keys and values. + # + # Example: + # stub_ldap_config( + # group_base: 'ou=groups,dc=example,dc=com', + # admin_group: 'my-admin-group' + # ) + def stub_ldap_config(messages) + messages.each do |config, value| + allow_any_instance_of(::Gitlab::LDAP::Config) + .to receive(config.to_sym).and_return(value) + end + end + + # Stub an LDAP person search and provide the return entry. Specify `nil` for + # `entry` to simulate when an LDAP person is not found + # + # Example: + # adapter = ::Gitlab::LDAP::Adapter.new('ldapmain', double(:ldap)) + # ldap_user_entry = ldap_user_entry('john_doe') + # + # stub_ldap_person_find_by_uid('john_doe', ldap_user_entry, adapter) + def stub_ldap_person_find_by_uid(uid, entry, provider = 'ldapmain') + return_value = ::Gitlab::LDAP::Person.new(entry, provider) if entry.present? + + allow(::Gitlab::LDAP::Person) + .to receive(:find_by_uid).with(uid, any_args).and_return(return_value) + end + + # Create a simple LDAP user entry. + def ldap_user_entry(uid) + entry = Net::LDAP::Entry.new + entry['dn'] = user_dn(uid) + entry['uid'] = uid + + entry + end +end diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb index e5f76afbfc0..c0b3e83244d 100644 --- a/spec/support/login_helpers.rb +++ b/spec/support/login_helpers.rb @@ -75,6 +75,7 @@ module LoginHelpers def logout find(".header-user-dropdown-toggle").click click_link "Sign out" + expect(page).to have_content('Signed out successfully') end # Logout without JavaScript driver diff --git a/spec/support/matchers/have_issuable_counts.rb b/spec/support/matchers/have_issuable_counts.rb new file mode 100644 index 00000000000..02605d6b70e --- /dev/null +++ b/spec/support/matchers/have_issuable_counts.rb @@ -0,0 +1,21 @@ +RSpec::Matchers.define :have_issuable_counts do |opts| + match do |actual| + expected_counts = opts.map do |state, count| + "#{state.to_s.humanize} #{count}" + end + + actual.within '.issues-state-filters' do + expected_counts.each do |expected_count| + expect(actual).to have_content(expected_count) + end + end + end + + description do + "displays the following issuable counts: #{expected_counts.inspect}" + end + + failure_message do + "expected the following issuable counts: #{expected_counts.inspect} to be displayed" + end +end diff --git a/spec/support/mentionable_shared_examples.rb b/spec/support/mentionable_shared_examples.rb index e876d44c166..f57c82809a6 100644 --- a/spec/support/mentionable_shared_examples.rb +++ b/spec/support/mentionable_shared_examples.rb @@ -9,7 +9,7 @@ shared_context 'mentionable context' do let(:author) { subject.author } let(:mentioned_issue) { create(:issue, project: project) } - let!(:mentioned_mr) { create(:merge_request, :simple, source_project: project) } + let!(:mentioned_mr) { create(:merge_request, source_project: project) } let(:mentioned_commit) { project.commit("HEAD~1") } let(:ext_proj) { create(:project, :public) } @@ -100,6 +100,7 @@ shared_examples 'an editable mentionable' do it 'creates new cross-reference notes when the mentionable text is edited' do subject.save + subject.create_cross_references! new_text = <<-MSG.strip_heredoc These references already existed: @@ -131,6 +132,7 @@ shared_examples 'an editable mentionable' do end # These two issues are new and should receive reference notes + # In the case of MergeRequests remember that cannot mention commits included in the MergeRequest new_issues.each do |newref| expect(SystemNoteService).to receive(:cross_reference). with(newref, subject.local_reference, author) diff --git a/spec/support/select2_helper.rb b/spec/support/select2_helper.rb index 04d25b5e9e9..35cc51725c6 100644 --- a/spec/support/select2_helper.rb +++ b/spec/support/select2_helper.rb @@ -11,7 +11,7 @@ # module Select2Helper - def select2(value, options={}) + def select2(value, options = {}) raise ArgumentError, 'options must be a Hash' unless options.kind_of?(Hash) selector = options.fetch(:from) diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb new file mode 100644 index 00000000000..5f9645ed44f --- /dev/null +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -0,0 +1,83 @@ +# Specifications for behavior common to all objects with executable attributes. +# It can take a `default_params`. + +shared_examples 'new issuable record that supports slash commands' do + let!(:project) { create(:project) } + let(:user) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let!(:milestone) { create(:milestone, project: project) } + let!(:labels) { create_list(:label, 3, project: project) } + let(:base_params) { { title: FFaker::Lorem.sentence(3) } } + let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) } + let(:issuable) { described_class.new(project, user, params).execute } + + context 'with labels in command only' do + let(:example_params) do + { + description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/unlabel ~#{labels.third.name}" + } + end + + it 'attaches labels to issuable' do + expect(issuable).to be_persisted + expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id]) + end + end + + context 'with labels in params and command' do + let(:example_params) do + { + label_ids: [labels.second.id], + description: "/label ~#{labels.first.name}\n/unlabel ~#{labels.third.name}" + } + end + + it 'attaches all labels to issuable' do + expect(issuable).to be_persisted + expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id]) + end + end + + context 'with assignee and milestone in command only' do + let(:example_params) do + { + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + it 'assigns and sets milestone to issuable' do + expect(issuable).to be_persisted + expect(issuable.assignee).to eq(assignee) + expect(issuable.milestone).to eq(milestone) + end + end + + context 'with assignee and milestone in params and command' do + let(:example_params) do + { + assignee: build_stubbed(:user), + milestone_id: double(:milestone), + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + it 'assigns and sets milestone to issuable from command' do + expect(issuable).to be_persisted + expect(issuable.assignee).to eq(assignee) + expect(issuable.milestone).to eq(milestone) + end + end + + describe '/close' do + let(:example_params) do + { + description: '/close' + } + end + + it 'returns an open issue' do + expect(issuable).to be_persisted + expect(issuable).to be_open + end + end +end diff --git a/spec/support/slash_commands_helpers.rb b/spec/support/slash_commands_helpers.rb new file mode 100644 index 00000000000..df483afa0e3 --- /dev/null +++ b/spec/support/slash_commands_helpers.rb @@ -0,0 +1,10 @@ +module SlashCommandsHelpers + def write_note(text) + Sidekiq::Testing.fake! do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: text + click_button 'Comment' + end + end + end +end diff --git a/spec/support/snippets_shared_examples.rb b/spec/support/snippets_shared_examples.rb new file mode 100644 index 00000000000..57dfff3471f --- /dev/null +++ b/spec/support/snippets_shared_examples.rb @@ -0,0 +1,18 @@ +# These shared examples expect a `snippets` array of snippets +RSpec.shared_examples 'paginated snippets' do |remote: false| + it "is limited to #{Snippet.default_per_page} items per page" do + expect(page.all('.snippets-list-holder .snippet-row').count).to eq(Snippet.default_per_page) + end + + context 'clicking on the link to the second page' do + before do + click_link('2') + wait_for_ajax if remote + end + + it 'shows the remaining snippets' do + remaining_snippets_count = [snippets.size - Snippet.default_per_page, Snippet.default_per_page].min + expect(page).to have_selector('.snippets-list-holder .snippet-row', count: remaining_snippets_count) + end + end +end diff --git a/spec/support/taskable_shared_examples.rb b/spec/support/taskable_shared_examples.rb index 927c72c7409..201614e45a4 100644 --- a/spec/support/taskable_shared_examples.rb +++ b/spec/support/taskable_shared_examples.rb @@ -3,30 +3,57 @@ # Requires a context containing: # subject { Issue or MergeRequest } shared_examples 'a Taskable' do - before do - subject.description = <<-EOT.strip_heredoc - * [ ] Task 1 - * [x] Task 2 - * [x] Task 3 - * [ ] Task 4 - * [ ] Task 5 - EOT + describe 'with multiple tasks' do + before do + subject.description = <<-EOT.strip_heredoc + * [ ] Task 1 + * [x] Task 2 + * [x] Task 3 + * [ ] Task 4 + * [ ] Task 5 + EOT + end + + it 'returns the correct task status' do + expect(subject.task_status).to match('2 of') + expect(subject.task_status).to match('5 tasks completed') + end + + describe '#tasks?' do + it 'returns true when object has tasks' do + expect(subject.tasks?).to eq true + end + + it 'returns false when object has no tasks' do + subject.description = 'Now I have no tasks' + expect(subject.tasks?).to eq false + end + end end - it 'returns the correct task status' do - expect(subject.task_status).to match('5 tasks') - expect(subject.task_status).to match('2 completed') - expect(subject.task_status).to match('3 remaining') + describe 'with an incomplete task' do + before do + subject.description = <<-EOT.strip_heredoc + * [ ] Task 1 + EOT + end + + it 'returns the correct task status' do + expect(subject.task_status).to match('0 of') + expect(subject.task_status).to match('1 task completed') + end end - describe '#tasks?' do - it 'returns true when object has tasks' do - expect(subject.tasks?).to eq true + describe 'with a complete task' do + before do + subject.description = <<-EOT.strip_heredoc + * [x] Task 1 + EOT end - it 'returns false when object has no tasks' do - subject.description = 'Now I have no tasks' - expect(subject.tasks?).to eq false + it 'returns the correct task status' do + expect(subject.task_status).to match('1 of') + expect(subject.task_status).to match('1 task completed') end end end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1c0c66969e3..d56274d0979 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -5,34 +5,44 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { - 'empty-branch' => '7efb185', - 'ends-with.json' => '98b0d8b3', - 'flatten-dir' => 'e56497b', - 'feature' => '0b4bc9a', - 'feature_conflict' => 'bb5206f', - 'fix' => '48f0be4', - 'improve/awesome' => '5937ac0', - 'markdown' => '0ed8c6c', - 'lfs' => 'be93687', - 'master' => '5937ac0', - "'test'" => 'e56497b', - 'orphaned-branch' => '45127a9', - 'binary-encoding' => '7b1cf43', - 'gitattributes' => '5a62481', - 'expand-collapse-diffs' => '4842455', - 'expand-collapse-files' => '025db92', - 'expand-collapse-lines' => '238e82d', - 'video' => '8879059', - 'crlf-diff' => '5938907' + 'not-merged-branch' => 'b83d6e3', + 'branch-merged' => '498214d', + 'empty-branch' => '7efb185', + 'ends-with.json' => '98b0d8b', + 'flatten-dir' => 'e56497b', + 'feature' => '0b4bc9a', + 'feature_conflict' => 'bb5206f', + 'fix' => '48f0be4', + 'improve/awesome' => '5937ac0', + 'markdown' => '0ed8c6c', + 'lfs' => 'be93687', + 'master' => 'b83d6e3', + "'test'" => 'e56497b', + 'orphaned-branch' => '45127a9', + 'binary-encoding' => '7b1cf43', + 'gitattributes' => '5a62481', + 'expand-collapse-diffs' => '4842455', + 'expand-collapse-files' => '025db92', + 'expand-collapse-lines' => '238e82d', + 'video' => '8879059', + 'crlf-diff' => '5938907', + 'conflict-start' => '75284c7', + 'conflict-resolvable' => '1450cd6', + 'conflict-binary-file' => '259a6fb', + 'conflict-contains-conflict-markers' => '5e0964c', + 'conflict-missing-side' => 'eb227b3', + 'conflict-non-utf8' => 'd0a293c', + 'conflict-too-large' => '39fa04f', } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily # need to keep all the branches in sync. # We currently only need a subset of the branches FORKED_BRANCH_SHA = { - 'add-submodule-version-bump' => '3f547c08', - 'master' => '5937ac0', - 'remove-submodule' => '2a33e0c0' + 'add-submodule-version-bump' => '3f547c0', + 'master' => '5937ac0', + 'remove-submodule' => '2a33e0c', + 'conflict-resolvable-fork' => '404fa3f' } # Test environment @@ -110,22 +120,7 @@ module TestEnv system(*%W(#{Gitlab.config.git.bin_path} clone -q #{clone_url} #{repo_path})) end - Dir.chdir(repo_path) do - branch_sha.each do |branch, sha| - # Try to reset without fetching to avoid using the network. - reset = %W(#{Gitlab.config.git.bin_path} update-ref refs/heads/#{branch} #{sha}) - unless system(*reset) - if system(*%W(#{Gitlab.config.git.bin_path} fetch origin)) - unless system(*reset) - raise 'The fetched test seed '\ - 'does not contain the required revision.' - end - else - raise 'Could not fetch test seed repository.' - end - end - end - end + set_repo_refs(repo_path, branch_sha) # We must copy bare repositories because we will push to them. system(git_env, *%W(#{Gitlab.config.git.bin_path} clone -q --bare #{repo_path} #{repo_path_bare})) @@ -137,6 +132,7 @@ module TestEnv FileUtils.mkdir_p(target_repo_path) FileUtils.cp_r("#{base_repo_path}/.", target_repo_path) FileUtils.chmod_R 0755, target_repo_path + set_repo_refs(target_repo_path, BRANCH_SHA) end def repos_path @@ -153,6 +149,7 @@ module TestEnv FileUtils.mkdir_p(target_repo_path) FileUtils.cp_r("#{base_repo_path}/.", target_repo_path) FileUtils.chmod_R 0755, target_repo_path + set_repo_refs(target_repo_path, FORKED_BRANCH_SHA) end # When no cached assets exist, manually hit the root path to create them @@ -202,4 +199,23 @@ module TestEnv def git_env { 'GIT_TEMPLATE_DIR' => '' } end + + def set_repo_refs(repo_path, branch_sha) + Dir.chdir(repo_path) do + branch_sha.each do |branch, sha| + # Try to reset without fetching to avoid using the network. + reset = %W(#{Gitlab.config.git.bin_path} update-ref refs/heads/#{branch} #{sha}) + unless system(*reset) + if system(*%W(#{Gitlab.config.git.bin_path} fetch origin)) + unless system(*reset) + raise 'The fetched test seed '\ + 'does not contain the required revision.' + end + else + raise 'Could not fetch test seed repository.' + end + end + end + end + end end diff --git a/spec/support/updating_mentions_shared_examples.rb b/spec/support/updating_mentions_shared_examples.rb new file mode 100644 index 00000000000..e0c59a5c280 --- /dev/null +++ b/spec/support/updating_mentions_shared_examples.rb @@ -0,0 +1,32 @@ +RSpec.shared_examples 'updating mentions' do |service_class| + let(:mentioned_user) { create(:user) } + let(:service_class) { service_class } + + before { project.team << [mentioned_user, :developer] } + + def update_mentionable(opts) + reset_delivered_emails! + + perform_enqueued_jobs do + service_class.new(project, user, opts).execute(mentionable) + end + + mentionable.reload + end + + context 'in title' do + before { update_mentionable(title: mentioned_user.to_reference) } + + it 'emails only the newly-mentioned user' do + should_only_email(mentioned_user) + end + end + + context 'in description' do + before { update_mentionable(description: mentioned_user.to_reference) } + + it 'emails only the newly-mentioned user' do + should_only_email(mentioned_user) + end + end +end diff --git a/spec/support/wait_for_vue_resource.rb b/spec/support/wait_for_vue_resource.rb new file mode 100644 index 00000000000..1029f84716f --- /dev/null +++ b/spec/support/wait_for_vue_resource.rb @@ -0,0 +1,7 @@ +module WaitForVueResource + def wait_for_vue_resource(spinner: true) + Timeout.timeout(Capybara.default_max_wait_time) do + loop until page.evaluate_script('Vue.activeResources').zero? + end + end +end diff --git a/spec/support/workhorse_helpers.rb b/spec/support/workhorse_helpers.rb index 107b6e30924..47673cd4c3a 100644 --- a/spec/support/workhorse_helpers.rb +++ b/spec/support/workhorse_helpers.rb @@ -13,4 +13,9 @@ module WorkhorseHelpers ] end end + + def workhorse_internal_api_request_header + jwt_token = JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') + { 'HTTP_' + Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER.upcase.tr('-', '_') => jwt_token } + end end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index d2c056d8e14..548e7780c36 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -42,7 +42,7 @@ describe 'gitlab:app namespace rake task' do before do allow(Dir).to receive(:glob).and_return([]) allow(Dir).to receive(:chdir) - allow(File).to receive(:exists?).and_return(true) + allow(File).to receive(:exist?).and_return(true) allow(Kernel).to receive(:system).and_return(true) allow(FileUtils).to receive(:cp_r).and_return(true) allow(FileUtils).to receive(:mv).and_return(true) @@ -53,7 +53,7 @@ describe 'gitlab:app namespace rake task' do let(:gitlab_version) { Gitlab::VERSION } - it 'should fail on mismatch' do + it 'fails on mismatch' do allow(YAML).to receive(:load_file). and_return({ gitlab_version: "not #{gitlab_version}" }) @@ -61,7 +61,7 @@ describe 'gitlab:app namespace rake task' do to raise_error(SystemExit) end - it 'should invoke restoration on match' do + it 'invokes restoration on match' do allow(YAML).to receive(:load_file). and_return({ gitlab_version: gitlab_version }) expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke) @@ -107,7 +107,7 @@ describe 'gitlab:app namespace rake task' do end context 'archive file permissions' do - it 'should set correct permissions on the tar file' do + it 'sets correct permissions on the tar file' do expect(File.exist?(@backup_tar)).to be_truthy expect(File::Stat.new(@backup_tar).mode.to_s(8)).to eq('100600') end @@ -127,7 +127,7 @@ describe 'gitlab:app namespace rake task' do end end - it 'should set correct permissions on the tar contents' do + it 'sets correct permissions on the tar contents' do tar_contents, exit_status = Gitlab::Popen.popen( %W{tar -tvf #{@backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz lfs.tar.gz registry.tar.gz} ) @@ -142,7 +142,7 @@ describe 'gitlab:app namespace rake task' do expect(tar_contents).not_to match(/^.{4,9}[rwx].* (database.sql.gz|uploads.tar.gz|repositories|builds.tar.gz|artifacts.tar.gz|registry.tar.gz)\/$/) end - it 'should delete temp directories' do + it 'deletes temp directories' do temp_dirs = Dir.glob( File.join(Gitlab.config.backup.path, '{db,repositories,uploads,builds,artifacts,lfs,registry}') ) @@ -153,7 +153,7 @@ describe 'gitlab:app namespace rake task' do context 'registry disabled' do let(:enable_registry) { false } - it 'should not create registry.tar.gz' do + it 'does not create registry.tar.gz' do tar_contents, exit_status = Gitlab::Popen.popen( %W{tar -tvf #{@backup_tar}} ) @@ -191,7 +191,7 @@ describe 'gitlab:app namespace rake task' do FileUtils.rm(@backup_tar) end - it 'should include repositories in all repository storages' do + it 'includes repositories in all repository storages' do tar_contents, exit_status = Gitlab::Popen.popen( %W{tar -tvf #{@backup_tar} repositories} ) diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index 36d03a224e4..fc52c04e78d 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -19,7 +19,7 @@ describe 'gitlab:db namespace rake task' do end describe 'configure' do - it 'should invoke db:migrate when schema has already been loaded' do + it 'invokes db:migrate when schema has already been loaded' do allow(ActiveRecord::Base.connection).to receive(:tables).and_return(['default']) expect(Rake::Task['db:migrate']).to receive(:invoke) expect(Rake::Task['db:schema:load']).not_to receive(:invoke) @@ -27,7 +27,7 @@ describe 'gitlab:db namespace rake task' do expect { run_rake_task('gitlab:db:configure') }.not_to raise_error end - it 'should invoke db:shema:load and db:seed_fu when schema is not loaded' do + it 'invokes db:shema:load and db:seed_fu when schema is not loaded' do allow(ActiveRecord::Base.connection).to receive(:tables).and_return([]) expect(Rake::Task['db:schema:load']).to receive(:invoke) expect(Rake::Task['db:seed_fu']).to receive(:invoke) @@ -35,7 +35,7 @@ describe 'gitlab:db namespace rake task' do expect { run_rake_task('gitlab:db:configure') }.not_to raise_error end - it 'should not invoke any other rake tasks during an error' do + it 'does not invoke any other rake tasks during an error' do allow(ActiveRecord::Base).to receive(:connection).and_raise(RuntimeError, 'error') expect(Rake::Task['db:migrate']).not_to receive(:invoke) expect(Rake::Task['db:schema:load']).not_to receive(:invoke) @@ -45,7 +45,7 @@ describe 'gitlab:db namespace rake task' do allow(ActiveRecord::Base).to receive(:connection).and_call_original end - it 'should not invoke seed after a failed schema_load' do + it 'does not invoke seed after a failed schema_load' do allow(ActiveRecord::Base.connection).to receive(:tables).and_return([]) allow(Rake::Task['db:schema:load']).to receive(:invoke).and_raise(RuntimeError, 'error') expect(Rake::Task['db:schema:load']).to receive(:invoke) diff --git a/spec/tasks/gitlab/users_rake_spec.rb b/spec/tasks/gitlab/users_rake_spec.rb new file mode 100644 index 00000000000..e6ebef82b78 --- /dev/null +++ b/spec/tasks/gitlab/users_rake_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' +require 'rake' + +describe 'gitlab:users namespace rake task' do + let(:enable_registry) { true } + + before :all do + Rake.application.rake_require 'tasks/gitlab/task_helpers' + Rake.application.rake_require 'tasks/gitlab/users' + + # empty task as env is already loaded + Rake::Task.define_task :environment + end + + def run_rake_task(task_name) + Rake::Task[task_name].reenable + Rake.application.invoke_task task_name + end + + describe 'clear_all_authentication_tokens' do + before do + # avoid writing task output to spec progress + allow($stdout).to receive :write + end + + context 'gitlab version' do + it 'clears the authentication token for all users' do + create_list(:user, 2) + + expect(User.pluck(:authentication_token)).to all(be_present) + + run_rake_task('gitlab:users:clear_all_authentication_tokens') + + expect(User.pluck(:authentication_token)).to all(be_nil) + end + end + end +end diff --git a/spec/teaspoon_env.rb b/spec/teaspoon_env.rb index 1a3bbb9c8cc..5ea020f313c 100644 --- a/spec/teaspoon_env.rb +++ b/spec/teaspoon_env.rb @@ -149,7 +149,7 @@ Teaspoon.configure do |config| # Specify that you always want a coverage configuration to be used. Otherwise, specify that you want coverage # on the CLI. # Set this to "true" or the name of your coverage config. - # config.use_coverage = nil + config.use_coverage = true # You can have multiple coverage configs by passing a name to config.coverage. # e.g. config.coverage :ci do |coverage| @@ -158,15 +158,15 @@ Teaspoon.configure do |config| # Which coverage reports Istanbul should generate. Correlates directly to what Istanbul supports. # # Available: text-summary, text, html, lcov, lcovonly, cobertura, teamcity - # coverage.reports = ["text-summary", "html"] + coverage.reports = ["text-summary", "html"] # The path that the coverage should be written to - when there's an artifact to write to disk. # Note: Relative to `config.root`. - # coverage.output_path = "coverage" + coverage.output_path = "coverage-javascript" # Assets to be ignored when generating coverage reports. Accepts an array of filenames or regular expressions. The # default excludes assets from vendor, gems and support libraries. - # coverage.ignore = [%r{/lib/ruby/gems/}, %r{/vendor/assets/}, %r{/support/}, %r{/(.+)_helper.}] + coverage.ignore = [%r{vendor/}, %r{spec/}] # Various thresholds requirements can be defined, and those thresholds will be checked at the end of a run. If any # aren't met the run will fail with a message. Thresholds can be defined as a percentage (0-100), or nil. diff --git a/spec/views/admin/dashboard/index.html.haml_spec.rb b/spec/views/admin/dashboard/index.html.haml_spec.rb index dae858a52f6..68d2d72876e 100644 --- a/spec/views/admin/dashboard/index.html.haml_spec.rb +++ b/spec/views/admin/dashboard/index.html.haml_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe 'admin/dashboard/index.html.haml' do - include Devise::TestHelpers + include Devise::Test::ControllerHelpers before do assign(:projects, create_list(:empty_project, 1)) diff --git a/spec/views/ci/lints/show.html.haml_spec.rb b/spec/views/ci/lints/show.html.haml_spec.rb new file mode 100644 index 00000000000..2dac5ee23c8 --- /dev/null +++ b/spec/views/ci/lints/show.html.haml_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +describe 'ci/lints/show' do + include Devise::TestHelpers + + describe 'XSS protection' do + let(:config_processor) { Ci::GitlabCiYamlProcessor.new(YAML.dump(content)) } + before do + assign(:status, true) + assign(:builds, config_processor.builds) + assign(:stages, config_processor.stages) + assign(:jobs, config_processor.jobs) + end + + context 'when builds attrbiutes contain HTML nodes' do + let(:content) do + { + rspec: { + script: '<h1>rspec</h1>', + stage: 'test' + } + } + end + + it 'does not render HTML elements' do + render + + expect(rendered).not_to have_css('h1', text: 'rspec') + end + end + + context 'when builds attributes do not contain HTML nodes' do + let(:content) do + { + rspec: { + script: 'rspec', + stage: 'test' + } + } + end + + it 'shows configuration in the table' do + render + + expect(rendered).to have_css('td pre', text: 'rspec') + end + end + end + + let(:content) do + { + build_template: { + script: './build.sh', + tags: ['dotnet'], + only: ['test@dude/repo'], + except: ['deploy'], + environment: 'testing' + } + } + end + + let(:config_processor) { Ci::GitlabCiYamlProcessor.new(YAML.dump(content)) } + + context 'when the content is valid' do + before do + assign(:status, true) + assign(:builds, config_processor.builds) + assign(:stages, config_processor.stages) + assign(:jobs, config_processor.jobs) + end + + it 'shows the correct values' do + render + + expect(rendered).to have_content('Tag list: dotnet') + expect(rendered).to have_content('Refs only: test@dude/repo') + expect(rendered).to have_content('Refs except: deploy') + expect(rendered).to have_content('Environment: testing') + expect(rendered).to have_content('When: on_success') + end + end + + context 'when the content is invalid' do + before do + assign(:status, false) + assign(:error, 'Undefined error') + end + + it 'shows error message' do + render + + expect(rendered).to have_content('Status: syntax is incorrect') + expect(rendered).to have_content('Error: Undefined error') + expect(rendered).not_to have_content('Tag list:') + end + end +end diff --git a/spec/views/devise/shared/_signin_box.html.haml_spec.rb b/spec/views/devise/shared/_signin_box.html.haml_spec.rb index 05a76ee4bdb..ee362e6fcb3 100644 --- a/spec/views/devise/shared/_signin_box.html.haml_spec.rb +++ b/spec/views/devise/shared/_signin_box.html.haml_spec.rb @@ -31,7 +31,7 @@ describe 'devise/shared/_signin_box' do def enable_crowd allow(view).to receive(:form_based_providers).and_return([:crowd]) allow(view).to receive(:crowd_enabled?).and_return(true) - allow(view).to receive(:user_omniauth_authorize_path).with('crowd'). + allow(view).to receive(:omniauth_authorize_path).with(:user, :crowd). and_return('/crowd') end end diff --git a/spec/views/layouts/_head.html.haml_spec.rb b/spec/views/layouts/_head.html.haml_spec.rb new file mode 100644 index 00000000000..3fddfb3b62f --- /dev/null +++ b/spec/views/layouts/_head.html.haml_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'layouts/_head' do + before do + stub_template 'layouts/_user_styles.html.haml' => '' + end + + it 'escapes HTML-safe strings in page_title' do + stub_helper_with_safe_string(:page_title) + + render + + expect(rendered).to match(%{content="foo" http-equiv="refresh"}) + end + + it 'escapes HTML-safe strings in page_description' do + stub_helper_with_safe_string(:page_description) + + render + + expect(rendered).to match(%{content="foo" http-equiv="refresh"}) + end + + it 'escapes HTML-safe strings in page_image' do + stub_helper_with_safe_string(:page_image) + + render + + expect(rendered).to match(%{content="foo" http-equiv="refresh"}) + end + + def stub_helper_with_safe_string(method) + allow_any_instance_of(PageLayoutHelper).to receive(method) + .and_return(%q{foo" http-equiv="refresh}.html_safe) + end +end diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb index 464051063d8..da43622d3f9 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/builds/show.html.haml_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe 'projects/builds/show' do - include Devise::TestHelpers + include Devise::Test::ControllerHelpers let(:project) { create(:project) } let(:pipeline) do @@ -59,14 +59,10 @@ describe 'projects/builds/show' do end it 'shows trigger variables in separate lines' do - expect(rendered).to have_css('code', text: variable_regexp('TRIGGER_KEY_1', 'TRIGGER_VALUE_1')) - expect(rendered).to have_css('code', text: variable_regexp('TRIGGER_KEY_2', 'TRIGGER_VALUE_2')) + expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_1') + expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_2') + expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_1') + expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2') end end - - private - - def variable_regexp(key, value) - /\A#{Regexp.escape("#{key}=#{value}")}\Z/ - end end diff --git a/spec/views/projects/issues/_related_branches.html.haml_spec.rb b/spec/views/projects/issues/_related_branches.html.haml_spec.rb index 78af61f15a7..c8a3d02d8fd 100644 --- a/spec/views/projects/issues/_related_branches.html.haml_spec.rb +++ b/spec/views/projects/issues/_related_branches.html.haml_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe 'projects/issues/_related_branches' do - include Devise::TestHelpers + include Devise::Test::ControllerHelpers let(:project) { create(:project) } let(:branch) { project.repository.find_branch('feature') } diff --git a/spec/views/projects/merge_requests/edit.html.haml_spec.rb b/spec/views/projects/merge_requests/edit.html.haml_spec.rb new file mode 100644 index 00000000000..3650b22c389 --- /dev/null +++ b/spec/views/projects/merge_requests/edit.html.haml_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'projects/merge_requests/edit.html.haml' do + include Devise::Test::ControllerHelpers + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:fork_project) { create(:project, forked_from_project: project) } + let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + let(:milestone) { create(:milestone, project: project) } + + let(:closed_merge_request) do + create(:closed_merge_request, + source_project: fork_project, + target_project: project, + author: user, + assignee: user, + milestone: milestone) + end + + before do + assign(:project, project) + assign(:merge_request, closed_merge_request) + + allow(view).to receive(:can?).and_return(true) + allow(view).to receive(:current_user) + .and_return(User.find(closed_merge_request.author_id)) + end + + context 'when a merge request without fork' do + it "shows editable fields" do + unlink_project.execute + closed_merge_request.reload + + render + + expect(rendered).to have_field('merge_request[title]') + expect(rendered).to have_field('merge_request[description]') + expect(rendered).to have_selector('#merge_request_assignee_id', visible: false) + expect(rendered).to have_selector('#merge_request_milestone_id', visible: false) + expect(rendered).not_to have_selector('#merge_request_target_branch', visible: false) + end + end + + context 'when a merge request with an existing source project is closed' do + it "shows editable fields" do + render + + expect(rendered).to have_field('merge_request[title]') + expect(rendered).to have_field('merge_request[description]') + expect(rendered).to have_selector('#merge_request_assignee_id', visible: false) + expect(rendered).to have_selector('#merge_request_milestone_id', visible: false) + expect(rendered).to have_selector('#merge_request_target_branch', visible: false) + end + end +end diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb new file mode 100644 index 00000000000..33cabd14913 --- /dev/null +++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe 'projects/merge_requests/show.html.haml' do + include Devise::Test::ControllerHelpers + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:fork_project) { create(:project, forked_from_project: project) } + let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + + let(:closed_merge_request) do + create(:closed_merge_request, + source_project: fork_project, + target_project: project, + author: user) + end + + before do + assign(:project, project) + assign(:merge_request, closed_merge_request) + assign(:commits_count, 0) + + allow(view).to receive(:can?).and_return(true) + end + + context 'when the merge request is closed' do + it 'shows the "Reopen" button' do + render + + expect(rendered).to have_css('a', visible: true, text: 'Reopen') + expect(rendered).to have_css('a', visible: false, text: 'Close') + end + + it 'does not show the "Reopen" button when the source project does not exist' do + unlink_project.execute + closed_merge_request.reload + + render + + expect(rendered).to have_css('a', visible: false, text: 'Reopen') + expect(rendered).to have_css('a', visible: false, text: 'Close') + end + end + + context 'when the merge request is open' do + it 'closes the merge request if the source project does not exist' do + closed_merge_request.update_attributes(state: 'open') + fork_project.destroy + + render + + expect(closed_merge_request.reload.state).to eq('closed') + expect(rendered).to have_css('a', visible: false, text: 'Reopen') + expect(rendered).to have_css('a', visible: false, text: 'Close') + end + end +end diff --git a/spec/views/projects/notes/_form.html.haml_spec.rb b/spec/views/projects/notes/_form.html.haml_spec.rb new file mode 100644 index 00000000000..b14b1ece2d0 --- /dev/null +++ b/spec/views/projects/notes/_form.html.haml_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'projects/notes/_form' do + include Devise::Test::ControllerHelpers + + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + before do + project.team << [user, :master] + assign(:project, project) + assign(:note, note) + + allow(view).to receive(:current_user).and_return(user) + + render + end + + %w[issue merge_request].each do |noteable| + context "with a note on #{noteable}" do + let(:note) { build(:"note_on_#{noteable}", project: project) } + + it 'says that only markdown is supported, not slash commands' do + expect(rendered).to have_content('Styling with Markdown and slash commands are supported') + end + end + end + + context 'with a note on a commit' do + let(:note) { build(:note_on_commit, project: project) } + + it 'says that only markdown is supported, not slash commands' do + expect(rendered).to have_content('Styling with Markdown is supported') + end + end +end diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb new file mode 100644 index 00000000000..bf027499c94 --- /dev/null +++ b/spec/views/projects/pipelines/show.html.haml_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe 'projects/pipelines/show' do + include Devise::Test::ControllerHelpers + + let(:project) { create(:project) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) } + + before do + controller.prepend_view_path('app/views/projects') + + create_build('build', 0, 'build', :success) + create_build('test', 1, 'rspec 0:2', :pending) + create_build('test', 1, 'rspec 1:2', :running) + create_build('test', 1, 'spinach 0:2', :created) + create_build('test', 1, 'spinach 1:2', :created) + create_build('test', 1, 'audit', :created) + create_build('deploy', 2, 'production', :created) + + create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3) + + assign(:project, project) + assign(:pipeline, pipeline) + + allow(view).to receive(:can?).and_return(true) + end + + it 'shows a graph with grouped stages' do + render + + expect(rendered).to have_css('.pipeline-graph') + expect(rendered).to have_css('.grouped-pipeline-dropdown') + + # stages + expect(rendered).to have_text('Build') + expect(rendered).to have_text('Test') + expect(rendered).to have_text('Deploy') + expect(rendered).to have_text('External') + + # builds + expect(rendered).to have_text('rspec') + expect(rendered).to have_text('spinach') + expect(rendered).to have_text('rspec 0:2') + expect(rendered).to have_text('production') + expect(rendered).to have_text('jenkins') + end + + private + + def create_build(stage, stage_idx, name, status) + create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status) + end +end diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb index 0f3fc1ee1ac..c381b1a86df 100644 --- a/spec/views/projects/tree/show.html.haml_spec.rb +++ b/spec/views/projects/tree/show.html.haml_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe 'projects/tree/show' do - include Devise::TestHelpers + include Devise::Test::ControllerHelpers let(:project) { create(:project) } let(:repository) { project.repository } diff --git a/spec/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb index 98deae0a588..788b92c1b84 100644 --- a/spec/workers/build_email_worker_spec.rb +++ b/spec/workers/build_email_worker_spec.rb @@ -5,7 +5,7 @@ describe BuildEmailWorker do let(:build) { create(:ci_build) } let(:user) { create(:user) } - let(:data) { Gitlab::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::Build.build(build) } subject { BuildEmailWorker.new } diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index 796751efe8d..036d037f3f9 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -2,19 +2,19 @@ require 'spec_helper' describe EmailsOnPushWorker do include RepoHelpers + include EmailSpec::Matchers let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } let(:recipients) { user.email } let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) } + let(:email) { ActionMailer::Base.deliveries.last } subject { EmailsOnPushWorker.new } describe "#perform" do context "when push is a new branch" do - let(:email) { ActionMailer::Base.deliveries.last } - before do data_new_branch = data.stringify_keys.merge("before" => Gitlab::Git::BLANK_SHA) @@ -31,8 +31,6 @@ describe EmailsOnPushWorker do end context "when push is a deleted branch" do - let(:email) { ActionMailer::Base.deliveries.last } - before do data_deleted_branch = data.stringify_keys.merge("after" => Gitlab::Git::BLANK_SHA) @@ -48,13 +46,38 @@ describe EmailsOnPushWorker do end end - context "when there are no errors in sending" do - let(:email) { ActionMailer::Base.deliveries.last } + context "when push is a force push to delete commits" do + before do + data_force_push = data.stringify_keys.merge( + "after" => data[:before], + "before" => data[:after] + ) + + subject.perform(project.id, recipients, data_force_push) + end + it "sends a mail with the correct subject" do + expect(email.subject).to include('adds bar folder and branch-test text file') + end + + it "mentions force pushing in the body" do + expect(email).to have_body_text("force push") + end + + it "sends the mail to the correct recipient" do + expect(email.to).to eq([user.email]) + end + end + + context "when there are no errors in sending" do before { perform } it "sends a mail with the correct subject" do - expect(email.subject).to include('Change some files') + expect(email.subject).to include('adds bar folder and branch-test text file') + end + + it "does not mention force pushing in the body" do + expect(email).not_to have_body_text("force push") end it "sends the mail to the correct recipient" do @@ -66,6 +89,7 @@ describe EmailsOnPushWorker do before do ActionMailer::Base.deliveries.clear allow(Notify).to receive(:repository_push_email).and_raise(Net::SMTPFatalError) + allow(subject).to receive_message_chain(:logger, :info) perform end diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index 7d6668920c0..73cbadc13d9 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -5,65 +5,42 @@ describe ExpireBuildArtifactsWorker do let(:worker) { described_class.new } + before { Sidekiq::Worker.clear_all } + describe '#perform' do before { build } - subject! { worker.perform } + subject! do + Sidekiq::Testing.fake! { worker.perform } + end context 'with expired artifacts' do let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } - it 'does expire' do - expect(build.reload.artifacts_expired?).to be_truthy - end - - it 'does remove files' do - expect(build.reload.artifacts_file.exists?).to be_falsey - end - - it 'does nullify artifacts_file column' do - expect(build.reload.artifacts_file_identifier).to be_nil + it 'enqueues that build' do + expect(jobs_enqueued.size).to eq(1) + expect(jobs_enqueued[0]["args"]).to eq([build.id]) end end context 'with not yet expired artifacts' do let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } - it 'does not expire' do - expect(build.reload.artifacts_expired?).to be_falsey - end - - it 'does not remove files' do - expect(build.reload.artifacts_file.exists?).to be_truthy - end - - it 'does not nullify artifacts_file column' do - expect(build.reload.artifacts_file_identifier).not_to be_nil + it 'does not enqueue that build' do + expect(jobs_enqueued.size).to eq(0) end end context 'without expire date' do let(:build) { create(:ci_build, :artifacts) } - it 'does not expire' do - expect(build.reload.artifacts_expired?).to be_falsey - end - - it 'does not remove files' do - expect(build.reload.artifacts_file.exists?).to be_truthy - end - - it 'does not nullify artifacts_file column' do - expect(build.reload.artifacts_file_identifier).not_to be_nil + it 'does not enqueue that build' do + expect(jobs_enqueued.size).to eq(0) end end - context 'for expired artifacts' do - let(:build) { create(:ci_build, artifacts_expire_at: Time.now - 7.days) } - - it 'is still expired' do - expect(build.reload.artifacts_expired?).to be_truthy - end + def jobs_enqueued + Sidekiq::Queues.jobs_by_worker['ExpireBuildInstanceArtifactsWorker'] end end end diff --git a/spec/workers/expire_build_instance_artifacts_worker_spec.rb b/spec/workers/expire_build_instance_artifacts_worker_spec.rb new file mode 100644 index 00000000000..2b140f2ba28 --- /dev/null +++ b/spec/workers/expire_build_instance_artifacts_worker_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe ExpireBuildInstanceArtifactsWorker do + include RepoHelpers + + let(:worker) { described_class.new } + + describe '#perform' do + before { build } + + subject! { worker.perform(build.id) } + + context 'with expired artifacts' do + let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + + it 'does expire' do + expect(build.reload.artifacts_expired?).to be_truthy + end + + it 'does remove files' do + expect(build.reload.artifacts_file.exists?).to be_falsey + end + + it 'does nullify artifacts_file column' do + expect(build.reload.artifacts_file_identifier).to be_nil + end + end + + context 'with not yet expired artifacts' do + let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } + + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_falsey + end + + it 'does not remove files' do + expect(build.reload.artifacts_file.exists?).to be_truthy + end + + it 'does not nullify artifacts_file column' do + expect(build.reload.artifacts_file_identifier).not_to be_nil + end + end + + context 'without expire date' do + let(:build) { create(:ci_build, :artifacts) } + + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_falsey + end + + it 'does not remove files' do + expect(build.reload.artifacts_file.exists?).to be_truthy + end + + it 'does not nullify artifacts_file column' do + expect(build.reload.artifacts_file_identifier).not_to be_nil + end + end + + context 'for expired artifacts' do + let(:build) { create(:ci_build, artifacts_expire_at: Time.now - 7.days) } + + it 'is still expired' do + expect(build.reload.artifacts_expired?).to be_truthy + end + end + end +end diff --git a/spec/workers/group_destroy_worker_spec.rb b/spec/workers/group_destroy_worker_spec.rb new file mode 100644 index 00000000000..4e4eaf9b2f7 --- /dev/null +++ b/spec/workers/group_destroy_worker_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe GroupDestroyWorker do + let(:group) { create(:group) } + let(:user) { create(:admin) } + let!(:project) { create(:project, namespace: group) } + + subject { GroupDestroyWorker.new } + + describe "#perform" do + it "deletes the project" do + subject.perform(group.id, user.id) + + expect(Group.all).not_to include(group) + expect(Project.all).not_to include(project) + expect(Dir.exist?(project.path)).to be_falsey + end + end +end diff --git a/spec/workers/pipeline_proccess_worker_spec.rb b/spec/workers/pipeline_proccess_worker_spec.rb new file mode 100644 index 00000000000..86e9d7f6684 --- /dev/null +++ b/spec/workers/pipeline_proccess_worker_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe PipelineProcessWorker do + describe '#perform' do + context 'when pipeline exists' do + let(:pipeline) { create(:ci_pipeline) } + + it 'processes pipeline' do + expect_any_instance_of(Ci::Pipeline).to receive(:process!) + + described_class.new.perform(pipeline.id) + end + end + + context 'when pipeline does not exist' do + it 'does not raise exception' do + expect { described_class.new.perform(123) } + .not_to raise_error + end + end + end +end diff --git a/spec/workers/pipeline_success_worker_spec.rb b/spec/workers/pipeline_success_worker_spec.rb new file mode 100644 index 00000000000..5e31cc2c8e7 --- /dev/null +++ b/spec/workers/pipeline_success_worker_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe PipelineSuccessWorker do + describe '#perform' do + context 'when pipeline exists' do + let(:pipeline) { create(:ci_pipeline, status: 'success') } + + it 'performs "merge when pipeline succeeds"' do + expect_any_instance_of( + MergeRequests::MergeWhenBuildSucceedsService + ).to receive(:trigger) + + described_class.new.perform(pipeline.id) + end + end + + context 'when pipeline does not exist' do + it 'does not raise exception' do + expect { described_class.new.perform(123) } + .not_to raise_error + end + end + end +end diff --git a/spec/workers/pipeline_update_worker_spec.rb b/spec/workers/pipeline_update_worker_spec.rb new file mode 100644 index 00000000000..0b456cfd0da --- /dev/null +++ b/spec/workers/pipeline_update_worker_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe PipelineUpdateWorker do + describe '#perform' do + context 'when pipeline exists' do + let(:pipeline) { create(:ci_pipeline) } + + it 'updates pipeline status' do + expect_any_instance_of(Ci::Pipeline).to receive(:update_status) + + described_class.new.perform(pipeline.id) + end + end + + context 'when pipeline does not exist' do + it 'does not raise exception' do + expect { described_class.new.perform(123) } + .not_to raise_error + end + end + end +end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 20b1a343c27..984acdade36 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -22,7 +22,7 @@ describe PostReceive do context "branches" do let(:changes) { "123456 789012 refs/heads/tést" } - it "should call GitTagPushService" do + it "calls GitTagPushService" do expect_any_instance_of(GitPushService).to receive(:execute).and_return(true) expect_any_instance_of(GitTagPushService).not_to receive(:execute) PostReceive.new.perform(pwd(project), key_id, base64_changes) @@ -32,7 +32,7 @@ describe PostReceive do context "tags" do let(:changes) { "123456 789012 refs/tags/tag" } - it "should call GitTagPushService" do + it "calls GitTagPushService" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).to receive(:execute).and_return(true) PostReceive.new.perform(pwd(project), key_id, base64_changes) @@ -42,7 +42,7 @@ describe PostReceive do context "merge-requests" do let(:changes) { "123456 789012 refs/merge-requests/123" } - it "should not call any of the services" do + it "does not call any of the services" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).not_to receive(:execute) PostReceive.new.perform(pwd(project), key_id, base64_changes) @@ -53,7 +53,13 @@ describe PostReceive do subject { PostReceive.new.perform(pwd(project), key_id, base64_changes) } context "creates a Ci::Pipeline for every change" do - before { stub_ci_pipeline_to_return_yaml_file } + before do + allow_any_instance_of(Ci::CreatePipelineService).to receive(:commit) do + OpenStruct.new(id: '123456') + end + allow_any_instance_of(Ci::CreatePipelineService).to receive(:branch?).and_return(true) + stub_ci_pipeline_to_return_yaml_file + end it { expect{ subject }.to change{ Ci::Pipeline.count }.by(2) } end @@ -73,7 +79,9 @@ describe PostReceive do end it "does not run if the author is not in the project" do - allow(Key).to receive(:find_by).with(hash_including(id: anything())) { nil } + allow_any_instance_of(Gitlab::GitPostReceive). + to receive(:identify_using_ssh_key). + and_return(nil) expect(project).not_to receive(:execute_hooks) @@ -84,7 +92,13 @@ describe PostReceive do allow(Project).to receive(:find_with_namespace).and_return(project) expect(project).to receive(:execute_hooks).twice expect(project).to receive(:execute_services).twice - expect(project).to receive(:update_merge_requests) + + PostReceive.new.perform(pwd(project), key_id, base64_changes) + end + + it "enqueues a UpdateMergeRequestsWorker job" do + allow(Project).to receive(:find_with_namespace).and_return(project) + expect(UpdateMergeRequestsWorker).to receive(:perform_async).with(project.id, project.owner.id, any_args) PostReceive.new.perform(pwd(project), key_id, base64_changes) end diff --git a/spec/workers/prune_old_events_worker_spec.rb b/spec/workers/prune_old_events_worker_spec.rb new file mode 100644 index 00000000000..35e1518a35e --- /dev/null +++ b/spec/workers/prune_old_events_worker_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe PruneOldEventsWorker do + describe '#perform' do + let!(:expired_event) { create(:event, author_id: 0, created_at: 13.months.ago) } + let!(:not_expired_event) { create(:event, author_id: 0, created_at: 1.day.ago) } + let!(:exactly_12_months_event) { create(:event, author_id: 0, created_at: 12.months.ago) } + + it 'prunes events older than 12 months' do + expect { subject.perform }.to change { Event.count }.by(-1) + expect(Event.find_by(id: expired_event.id)).to be_nil + end + + it 'leaves fresh events' do + subject.perform + expect(not_expired_event.reload).to be_present + end + + it 'leaves events from exactly 12 months ago' do + subject.perform + expect(exactly_12_months_event).to be_present + end + end +end diff --git a/spec/workers/remove_expired_group_links_worker_spec.rb b/spec/workers/remove_expired_group_links_worker_spec.rb new file mode 100644 index 00000000000..689bc3d27b4 --- /dev/null +++ b/spec/workers/remove_expired_group_links_worker_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe RemoveExpiredGroupLinksWorker do + describe '#perform' do + let!(:expired_project_group_link) { create(:project_group_link, expires_at: 1.hour.ago) } + let!(:project_group_link_expiring_in_future) { create(:project_group_link, expires_at: 10.days.from_now) } + let!(:non_expiring_project_group_link) { create(:project_group_link, expires_at: nil) } + + it 'removes expired group links' do + expect { subject.perform }.to change { ProjectGroupLink.count }.by(-1) + expect(ProjectGroupLink.find_by(id: expired_project_group_link.id)).to be_nil + end + + it 'leaves group links that expire in the future' do + subject.perform + expect(project_group_link_expiring_in_future.reload).to be_present + end + + it 'leaves group links that do not expire at all' do + subject.perform + expect(non_expiring_project_group_link.reload).to be_present + end + end +end diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb new file mode 100644 index 00000000000..402aa1e714e --- /dev/null +++ b/spec/workers/remove_expired_members_worker_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe RemoveExpiredMembersWorker do + let(:worker) { RemoveExpiredMembersWorker.new } + + describe '#perform' do + context 'project members' do + let!(:expired_project_member) { create(:project_member, expires_at: 1.hour.ago, access_level: GroupMember::DEVELOPER) } + let!(:project_member_expiring_in_future) { create(:project_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) } + let!(:non_expiring_project_member) { create(:project_member, expires_at: nil, access_level: GroupMember::DEVELOPER) } + + it 'removes expired members' do + expect { worker.perform }.to change { Member.count }.by(-1) + expect(Member.find_by(id: expired_project_member.id)).to be_nil + end + + it 'leaves members that expire in the future' do + worker.perform + expect(project_member_expiring_in_future.reload).to be_present + end + + it 'leaves members that do not expire at all' do + worker.perform + expect(non_expiring_project_member.reload).to be_present + end + end + + context 'group members' do + let!(:expired_group_member) { create(:group_member, expires_at: 1.hour.ago, access_level: GroupMember::DEVELOPER) } + let!(:group_member_expiring_in_future) { create(:group_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) } + let!(:non_expiring_group_member) { create(:group_member, expires_at: nil, access_level: GroupMember::DEVELOPER) } + + it 'removes expired members' do + expect { worker.perform }.to change { Member.count }.by(-1) + expect(Member.find_by(id: expired_group_member.id)).to be_nil + end + + it 'leaves members that expire in the future' do + worker.perform + expect(group_member_expiring_in_future.reload).to be_present + end + + it 'leaves members that do not expire at all' do + worker.perform + expect(non_expiring_group_member.reload).to be_present + end + end + + context 'when the last group owner expires' do + let!(:expired_group_owner) { create(:group_member, expires_at: 1.hour.ago, access_level: GroupMember::OWNER) } + + it 'does not delete the owner' do + worker.perform + expect(expired_group_owner.reload).to be_present + end + end + end +end diff --git a/spec/workers/repository_check/single_repository_worker_spec.rb b/spec/workers/repository_check/single_repository_worker_spec.rb index 05e07789dac..59cfb2c8e3a 100644 --- a/spec/workers/repository_check/single_repository_worker_spec.rb +++ b/spec/workers/repository_check/single_repository_worker_spec.rb @@ -5,7 +5,7 @@ describe RepositoryCheck::SingleRepositoryWorker do subject { described_class.new } it 'passes when the project has no push events' do - project = create(:project_empty_repo, wiki_enabled: false) + project = create(:project_empty_repo, wiki_access_level: ProjectFeature::DISABLED) project.events.destroy_all break_repo(project) @@ -25,7 +25,7 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'fails if the wiki repository is broken' do - project = create(:project_empty_repo, wiki_enabled: true) + project = create(:project_empty_repo, wiki_access_level: ProjectFeature::ENABLED) project.create_wiki # Test sanity: everything should be fine before the wiki repo is broken @@ -39,7 +39,7 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'skips wikis when disabled' do - project = create(:project_empty_repo, wiki_enabled: false) + project = create(:project_empty_repo, wiki_access_level: ProjectFeature::DISABLED) # Make sure the test would fail if the wiki repo was checked break_wiki(project) @@ -49,7 +49,7 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'creates missing wikis' do - project = create(:project_empty_repo, wiki_enabled: true) + project = create(:project_empty_repo, wiki_access_level: ProjectFeature::ENABLED) FileUtils.rm_rf(wiki_path(project)) subject.perform(project.id) diff --git a/spec/workers/trending_projects_worker_spec.rb b/spec/workers/trending_projects_worker_spec.rb new file mode 100644 index 00000000000..c3c6fdcf2d5 --- /dev/null +++ b/spec/workers/trending_projects_worker_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe TrendingProjectsWorker do + describe '#perform' do + it 'refreshes the trending projects' do + expect(TrendingProject).to receive(:refresh!) + + described_class.new.perform + end + end +end diff --git a/spec/workers/update_merge_requests_worker_spec.rb b/spec/workers/update_merge_requests_worker_spec.rb new file mode 100644 index 00000000000..c78a69eda67 --- /dev/null +++ b/spec/workers/update_merge_requests_worker_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe UpdateMergeRequestsWorker do + include RepoHelpers + + let(:project) { create(:project) } + let(:user) { create(:user) } + + subject { described_class.new } + + describe '#perform' do + let(:oldrev) { "123456" } + let(:newrev) { "789012" } + let(:ref) { "refs/heads/test" } + + def perform + subject.perform(project.id, user.id, oldrev, newrev, ref) + end + + it 'executes MergeRequests::RefreshService with expected values' do + expect(MergeRequests::RefreshService).to receive(:new).with(project, user).and_call_original + expect_any_instance_of(MergeRequests::RefreshService).to receive(:execute).with(oldrev, newrev, ref) + + perform + end + + it 'executes SystemHooksService with expected values' do + push_data = double('push_data') + expect(Gitlab::DataBuilder::Push).to receive(:build).with(project, user, oldrev, newrev, ref, []).and_return(push_data) + + system_hook_service = double('system_hook_service') + expect(SystemHooksService).to receive(:new).and_return(system_hook_service) + expect(system_hook_service).to receive(:execute_hooks).with(push_data, :push_hooks) + + perform + end + end +end diff --git a/vendor/assets/javascripts/Chart.js b/vendor/assets/javascripts/Chart.js index c264262ba73..c264262ba73 100755..100644 --- a/vendor/assets/javascripts/Chart.js +++ b/vendor/assets/javascripts/Chart.js diff --git a/vendor/assets/javascripts/Sortable.js b/vendor/assets/javascripts/Sortable.js new file mode 100644 index 00000000000..eca7c5012b2 --- /dev/null +++ b/vendor/assets/javascripts/Sortable.js @@ -0,0 +1,1285 @@ +/**! + * Sortable + * @author RubaXa <trash@rubaxa.org> + * @license MIT + */ + + +(function (factory) { + "use strict"; + + if (typeof define === "function" && define.amd) { + define(factory); + } + else if (typeof module != "undefined" && typeof module.exports != "undefined") { + module.exports = factory(); + } + else if (typeof Package !== "undefined") { + Sortable = factory(); // export for Meteor.js + } + else { + /* jshint sub:true */ + window["Sortable"] = factory(); + } +})(function () { + "use strict"; + + var dragEl, + parentEl, + ghostEl, + cloneEl, + rootEl, + nextEl, + + scrollEl, + scrollParentEl, + + lastEl, + lastCSS, + lastParentCSS, + + oldIndex, + newIndex, + + activeGroup, + autoScroll = {}, + + tapEvt, + touchEvt, + + moved, + + /** @const */ + RSPACE = /\s+/g, + + expando = 'Sortable' + (new Date).getTime(), + + win = window, + document = win.document, + parseInt = win.parseInt, + + supportDraggable = !!('draggable' in document.createElement('div')), + supportCssPointerEvents = (function (el) { + el = document.createElement('x'); + el.style.cssText = 'pointer-events:auto'; + return el.style.pointerEvents === 'auto'; + })(), + + _silent = false, + + abs = Math.abs, + min = Math.min, + slice = [].slice, + + touchDragOverListeners = [], + + _autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) { + // Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521 + if (rootEl && options.scroll) { + var el, + rect, + sens = options.scrollSensitivity, + speed = options.scrollSpeed, + + x = evt.clientX, + y = evt.clientY, + + winWidth = window.innerWidth, + winHeight = window.innerHeight, + + vx, + vy + ; + + // Delect scrollEl + if (scrollParentEl !== rootEl) { + scrollEl = options.scroll; + scrollParentEl = rootEl; + + if (scrollEl === true) { + scrollEl = rootEl; + + do { + if ((scrollEl.offsetWidth < scrollEl.scrollWidth) || + (scrollEl.offsetHeight < scrollEl.scrollHeight) + ) { + break; + } + /* jshint boss:true */ + } while (scrollEl = scrollEl.parentNode); + } + } + + if (scrollEl) { + el = scrollEl; + rect = scrollEl.getBoundingClientRect(); + vx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens); + vy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens); + } + + + if (!(vx || vy)) { + vx = (winWidth - x <= sens) - (x <= sens); + vy = (winHeight - y <= sens) - (y <= sens); + + /* jshint expr:true */ + (vx || vy) && (el = win); + } + + + if (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) { + autoScroll.el = el; + autoScroll.vx = vx; + autoScroll.vy = vy; + + clearInterval(autoScroll.pid); + + if (el) { + autoScroll.pid = setInterval(function () { + if (el === win) { + win.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed); + } else { + vy && (el.scrollTop += vy * speed); + vx && (el.scrollLeft += vx * speed); + } + }, 24); + } + } + } + }, 30), + + _prepareGroup = function (options) { + var group = options.group; + + if (!group || typeof group != 'object') { + group = options.group = {name: group}; + } + + ['pull', 'put'].forEach(function (key) { + if (!(key in group)) { + group[key] = true; + } + }); + + options.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' '; + } + ; + + + + /** + * @class Sortable + * @param {HTMLElement} el + * @param {Object} [options] + */ + function Sortable(el, options) { + if (!(el && el.nodeType && el.nodeType === 1)) { + throw 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el); + } + + this.el = el; // root element + this.options = options = _extend({}, options); + + + // Export instance + el[expando] = this; + + + // Default options + var defaults = { + group: Math.random(), + sort: true, + disabled: false, + store: null, + handle: null, + scroll: true, + scrollSensitivity: 30, + scrollSpeed: 10, + draggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*', + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + ignore: 'a, img', + filter: null, + animation: 0, + setData: function (dataTransfer, dragEl) { + dataTransfer.setData('Text', dragEl.textContent); + }, + dropBubble: false, + dragoverBubble: false, + dataIdAttr: 'data-id', + delay: 0, + forceFallback: false, + fallbackClass: 'sortable-fallback', + fallbackOnBody: false, + fallbackTolerance: 0 + }; + + + // Set default options + for (var name in defaults) { + !(name in options) && (options[name] = defaults[name]); + } + + _prepareGroup(options); + + // Bind all private methods + for (var fn in this) { + if (fn.charAt(0) === '_') { + this[fn] = this[fn].bind(this); + } + } + + // Setup drag mode + this.nativeDraggable = options.forceFallback ? false : supportDraggable; + + // Bind events + _on(el, 'mousedown', this._onTapStart); + _on(el, 'touchstart', this._onTapStart); + + if (this.nativeDraggable) { + _on(el, 'dragover', this); + _on(el, 'dragenter', this); + } + + touchDragOverListeners.push(this._onDragOver); + + // Restore sorting + options.store && this.sort(options.store.get(this)); + } + + + Sortable.prototype = /** @lends Sortable.prototype */ { + constructor: Sortable, + + _onTapStart: function (/** Event|TouchEvent */evt) { + var _this = this, + el = this.el, + options = this.options, + type = evt.type, + touch = evt.touches && evt.touches[0], + target = (touch || evt).target, + originalTarget = target, + filter = options.filter, + startIndex; + + // Don't trigger start event when an element is been dragged, otherwise the evt.oldindex always wrong when set option.group. + if (dragEl) { + return; + } + + if (type === 'mousedown' && evt.button !== 0 || options.disabled) { + return; // only left button or enabled + } + + target = _closest(target, options.draggable, el); + + if (!target) { + return; + } + + if (options.handle && !_closest(originalTarget, options.handle, el)) { + return; + } + + // Get the index of the dragged element within its parent + startIndex = _index(target, options.draggable); + + // Check filter + if (typeof filter === 'function') { + if (filter.call(this, evt, target, this)) { + _dispatchEvent(_this, originalTarget, 'filter', target, el, startIndex); + evt.preventDefault(); + return; // cancel dnd + } + } + else if (filter) { + filter = filter.split(',').some(function (criteria) { + criteria = _closest(originalTarget, criteria.trim(), el); + + if (criteria) { + _dispatchEvent(_this, criteria, 'filter', target, el, startIndex); + return true; + } + }); + + if (filter) { + evt.preventDefault(); + return; // cancel dnd + } + } + + // Prepare `dragstart` + this._prepareDragStart(evt, touch, target, startIndex); + }, + + _prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target, /** Number */startIndex) { + var _this = this, + el = _this.el, + options = _this.options, + ownerDocument = el.ownerDocument, + dragStartFn; + + if (target && !dragEl && (target.parentNode === el)) { + tapEvt = evt; + + rootEl = el; + dragEl = target; + parentEl = dragEl.parentNode; + nextEl = dragEl.nextSibling; + activeGroup = options.group; + oldIndex = startIndex; + + this._lastX = (touch || evt).clientX; + this._lastY = (touch || evt).clientY; + + dragStartFn = function () { + // Delayed drag has been triggered + // we can re-enable the events: touchmove/mousemove + _this._disableDelayedDrag(); + + // Make the element draggable + dragEl.draggable = true; + + // Chosen item + _toggleClass(dragEl, _this.options.chosenClass, true); + + // Bind the events: dragstart/dragend + _this._triggerDragStart(touch); + + // Drag start event + _dispatchEvent(_this, rootEl, 'choose', dragEl, rootEl, oldIndex); + }; + + // Disable "draggable" + options.ignore.split(',').forEach(function (criteria) { + _find(dragEl, criteria.trim(), _disableDraggable); + }); + + _on(ownerDocument, 'mouseup', _this._onDrop); + _on(ownerDocument, 'touchend', _this._onDrop); + _on(ownerDocument, 'touchcancel', _this._onDrop); + + if (options.delay) { + // If the user moves the pointer or let go the click or touch + // before the delay has been reached: + // disable the delayed drag + _on(ownerDocument, 'mouseup', _this._disableDelayedDrag); + _on(ownerDocument, 'touchend', _this._disableDelayedDrag); + _on(ownerDocument, 'touchcancel', _this._disableDelayedDrag); + _on(ownerDocument, 'mousemove', _this._disableDelayedDrag); + _on(ownerDocument, 'touchmove', _this._disableDelayedDrag); + + _this._dragStartTimer = setTimeout(dragStartFn, options.delay); + } else { + dragStartFn(); + } + } + }, + + _disableDelayedDrag: function () { + var ownerDocument = this.el.ownerDocument; + + clearTimeout(this._dragStartTimer); + _off(ownerDocument, 'mouseup', this._disableDelayedDrag); + _off(ownerDocument, 'touchend', this._disableDelayedDrag); + _off(ownerDocument, 'touchcancel', this._disableDelayedDrag); + _off(ownerDocument, 'mousemove', this._disableDelayedDrag); + _off(ownerDocument, 'touchmove', this._disableDelayedDrag); + }, + + _triggerDragStart: function (/** Touch */touch) { + if (touch) { + // Touch device support + tapEvt = { + target: dragEl, + clientX: touch.clientX, + clientY: touch.clientY + }; + + this._onDragStart(tapEvt, 'touch'); + } + else if (!this.nativeDraggable) { + this._onDragStart(tapEvt, true); + } + else { + _on(dragEl, 'dragend', this); + _on(rootEl, 'dragstart', this._onDragStart); + } + + try { + if (document.selection) { + document.selection.empty(); + } else { + window.getSelection().removeAllRanges(); + } + } catch (err) { + } + }, + + _dragStarted: function () { + if (rootEl && dragEl) { + // Apply effect + _toggleClass(dragEl, this.options.ghostClass, true); + + Sortable.active = this; + + // Drag start event + _dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex); + } + }, + + _emulateDragOver: function () { + if (touchEvt) { + if (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) { + return; + } + + this._lastX = touchEvt.clientX; + this._lastY = touchEvt.clientY; + + if (!supportCssPointerEvents) { + _css(ghostEl, 'display', 'none'); + } + + var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY), + parent = target, + groupName = ' ' + this.options.group.name + '', + i = touchDragOverListeners.length; + + if (parent) { + do { + if (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) { + while (i--) { + touchDragOverListeners[i]({ + clientX: touchEvt.clientX, + clientY: touchEvt.clientY, + target: target, + rootEl: parent + }); + } + + break; + } + + target = parent; // store last element + } + /* jshint boss:true */ + while (parent = parent.parentNode); + } + + if (!supportCssPointerEvents) { + _css(ghostEl, 'display', ''); + } + } + }, + + + _onTouchMove: function (/**TouchEvent*/evt) { + if (tapEvt) { + var options = this.options, + fallbackTolerance = options.fallbackTolerance, + touch = evt.touches ? evt.touches[0] : evt, + dx = touch.clientX - tapEvt.clientX, + dy = touch.clientY - tapEvt.clientY, + translate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)'; + + // only set the status to dragging, when we are actually dragging + if (!Sortable.active) { + if (fallbackTolerance && + min(abs(touch.clientX - this._lastX), abs(touch.clientY - this._lastY)) < fallbackTolerance + ) { + return; + } + + this._dragStarted(); + } + + // as well as creating the ghost element on the document body + this._appendGhost(); + + moved = true; + touchEvt = touch; + + _css(ghostEl, 'webkitTransform', translate3d); + _css(ghostEl, 'mozTransform', translate3d); + _css(ghostEl, 'msTransform', translate3d); + _css(ghostEl, 'transform', translate3d); + + evt.preventDefault(); + } + }, + + _appendGhost: function () { + if (!ghostEl) { + var rect = dragEl.getBoundingClientRect(), + css = _css(dragEl), + options = this.options, + ghostRect; + + ghostEl = dragEl.cloneNode(true); + + _toggleClass(ghostEl, options.ghostClass, false); + _toggleClass(ghostEl, options.fallbackClass, true); + + _css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10)); + _css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10)); + _css(ghostEl, 'width', rect.width); + _css(ghostEl, 'height', rect.height); + _css(ghostEl, 'opacity', '0.8'); + _css(ghostEl, 'position', 'fixed'); + _css(ghostEl, 'zIndex', '100000'); + _css(ghostEl, 'pointerEvents', 'none'); + + options.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl); + + // Fixing dimensions. + ghostRect = ghostEl.getBoundingClientRect(); + _css(ghostEl, 'width', rect.width * 2 - ghostRect.width); + _css(ghostEl, 'height', rect.height * 2 - ghostRect.height); + } + }, + + _onDragStart: function (/**Event*/evt, /**boolean*/useFallback) { + var dataTransfer = evt.dataTransfer, + options = this.options; + + this._offUpEvents(); + + if (activeGroup.pull == 'clone') { + cloneEl = dragEl.cloneNode(true); + _css(cloneEl, 'display', 'none'); + rootEl.insertBefore(cloneEl, dragEl); + _dispatchEvent(this, rootEl, 'clone', dragEl); + } + + if (useFallback) { + if (useFallback === 'touch') { + // Bind touch events + _on(document, 'touchmove', this._onTouchMove); + _on(document, 'touchend', this._onDrop); + _on(document, 'touchcancel', this._onDrop); + } else { + // Old brwoser + _on(document, 'mousemove', this._onTouchMove); + _on(document, 'mouseup', this._onDrop); + } + + this._loopId = setInterval(this._emulateDragOver, 50); + } + else { + if (dataTransfer) { + dataTransfer.effectAllowed = 'move'; + options.setData && options.setData.call(this, dataTransfer, dragEl); + } + + _on(document, 'drop', this); + setTimeout(this._dragStarted, 0); + } + }, + + _onDragOver: function (/**Event*/evt) { + var el = this.el, + target, + dragRect, + revert, + options = this.options, + group = options.group, + groupPut = group.put, + isOwner = (activeGroup === group), + canSort = options.sort; + + if (evt.preventDefault !== void 0) { + evt.preventDefault(); + !options.dragoverBubble && evt.stopPropagation(); + } + + moved = true; + + if (activeGroup && !options.disabled && + (isOwner + ? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list + : activeGroup.pull && groupPut && ( + (activeGroup.name === group.name) || // by Name + (groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array + ) + ) && + (evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback + ) { + // Smart auto-scrolling + _autoScroll(evt, options, this.el); + + if (_silent) { + return; + } + + target = _closest(evt.target, options.draggable, el); + dragRect = dragEl.getBoundingClientRect(); + + if (revert) { + _cloneHide(true); + parentEl = rootEl; // actualization + + if (cloneEl || nextEl) { + rootEl.insertBefore(dragEl, cloneEl || nextEl); + } + else if (!canSort) { + rootEl.appendChild(dragEl); + } + + return; + } + + + if ((el.children.length === 0) || (el.children[0] === ghostEl) || + (el === evt.target) && (target = _ghostIsLast(el, evt)) + ) { + + if (target) { + if (target.animated) { + return; + } + + targetRect = target.getBoundingClientRect(); + } + + _cloneHide(isOwner); + + if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) { + if (!dragEl.contains(el)) { + el.appendChild(dragEl); + parentEl = el; // actualization + } + + this._animate(dragRect, dragEl); + target && this._animate(targetRect, target); + } + } + else if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) { + if (lastEl !== target) { + lastEl = target; + lastCSS = _css(target); + lastParentCSS = _css(target.parentNode); + } + + + var targetRect = target.getBoundingClientRect(), + width = targetRect.right - targetRect.left, + height = targetRect.bottom - targetRect.top, + floating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display) + || (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0), + isWide = (target.offsetWidth > dragEl.offsetWidth), + isLong = (target.offsetHeight > dragEl.offsetHeight), + halfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5, + nextSibling = target.nextElementSibling, + moveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect), + after + ; + + if (moveVector !== false) { + _silent = true; + setTimeout(_unsilent, 30); + + _cloneHide(isOwner); + + if (moveVector === 1 || moveVector === -1) { + after = (moveVector === 1); + } + else if (floating) { + var elTop = dragEl.offsetTop, + tgTop = target.offsetTop; + + if (elTop === tgTop) { + after = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide; + } + else if (target.previousElementSibling === dragEl || dragEl.previousElementSibling === target) { + after = (evt.clientY - targetRect.top) / height > 0.5; + } else { + after = tgTop > elTop; + } + } else { + after = (nextSibling !== dragEl) && !isLong || halfway && isLong; + } + + if (!dragEl.contains(el)) { + if (after && !nextSibling) { + el.appendChild(dragEl); + } else { + target.parentNode.insertBefore(dragEl, after ? nextSibling : target); + } + } + + parentEl = dragEl.parentNode; // actualization + + this._animate(dragRect, dragEl); + this._animate(targetRect, target); + } + } + } + }, + + _animate: function (prevRect, target) { + var ms = this.options.animation; + + if (ms) { + var currentRect = target.getBoundingClientRect(); + + _css(target, 'transition', 'none'); + _css(target, 'transform', 'translate3d(' + + (prevRect.left - currentRect.left) + 'px,' + + (prevRect.top - currentRect.top) + 'px,0)' + ); + + target.offsetWidth; // repaint + + _css(target, 'transition', 'all ' + ms + 'ms'); + _css(target, 'transform', 'translate3d(0,0,0)'); + + clearTimeout(target.animated); + target.animated = setTimeout(function () { + _css(target, 'transition', ''); + _css(target, 'transform', ''); + target.animated = false; + }, ms); + } + }, + + _offUpEvents: function () { + var ownerDocument = this.el.ownerDocument; + + _off(document, 'touchmove', this._onTouchMove); + _off(ownerDocument, 'mouseup', this._onDrop); + _off(ownerDocument, 'touchend', this._onDrop); + _off(ownerDocument, 'touchcancel', this._onDrop); + }, + + _onDrop: function (/**Event*/evt) { + var el = this.el, + options = this.options; + + clearInterval(this._loopId); + clearInterval(autoScroll.pid); + clearTimeout(this._dragStartTimer); + + // Unbind events + _off(document, 'mousemove', this._onTouchMove); + + if (this.nativeDraggable) { + _off(document, 'drop', this); + _off(el, 'dragstart', this._onDragStart); + } + + this._offUpEvents(); + + if (evt) { + if (moved) { + evt.preventDefault(); + !options.dropBubble && evt.stopPropagation(); + } + + ghostEl && ghostEl.parentNode.removeChild(ghostEl); + + if (dragEl) { + if (this.nativeDraggable) { + _off(dragEl, 'dragend', this); + } + + _disableDraggable(dragEl); + + // Remove class's + _toggleClass(dragEl, this.options.ghostClass, false); + _toggleClass(dragEl, this.options.chosenClass, false); + + if (rootEl !== parentEl) { + newIndex = _index(dragEl, options.draggable); + + if (newIndex >= 0) { + // drag from one list and drop into another + _dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + + // Add event + _dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex); + + // Remove event + _dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex); + } + } + else { + // Remove clone + cloneEl && cloneEl.parentNode.removeChild(cloneEl); + + if (dragEl.nextSibling !== nextEl) { + // Get the index of the dragged element within its parent + newIndex = _index(dragEl, options.draggable); + + if (newIndex >= 0) { + // drag & drop within the same list + _dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex); + _dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex); + } + } + } + + if (Sortable.active) { + if (newIndex === null || newIndex === -1) { + newIndex = oldIndex; + } + + _dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex); + + // Save sorting + this.save(); + } + } + + } + + this._nulling(); + }, + + _nulling: function () { + rootEl = + dragEl = + parentEl = + ghostEl = + nextEl = + cloneEl = + + scrollEl = + scrollParentEl = + + tapEvt = + touchEvt = + + moved = + newIndex = + + lastEl = + lastCSS = + + activeGroup = + Sortable.active = null; + }, + + handleEvent: function (/**Event*/evt) { + var type = evt.type; + + if (type === 'dragover' || type === 'dragenter') { + if (dragEl) { + this._onDragOver(evt); + _globalDragOver(evt); + } + } + else if (type === 'drop' || type === 'dragend') { + this._onDrop(evt); + } + }, + + + /** + * Serializes the item into an array of string. + * @returns {String[]} + */ + toArray: function () { + var order = [], + el, + children = this.el.children, + i = 0, + n = children.length, + options = this.options; + + for (; i < n; i++) { + el = children[i]; + if (_closest(el, options.draggable, this.el)) { + order.push(el.getAttribute(options.dataIdAttr) || _generateId(el)); + } + } + + return order; + }, + + + /** + * Sorts the elements according to the array. + * @param {String[]} order order of the items + */ + sort: function (order) { + var items = {}, rootEl = this.el; + + this.toArray().forEach(function (id, i) { + var el = rootEl.children[i]; + + if (_closest(el, this.options.draggable, rootEl)) { + items[id] = el; + } + }, this); + + order.forEach(function (id) { + if (items[id]) { + rootEl.removeChild(items[id]); + rootEl.appendChild(items[id]); + } + }); + }, + + + /** + * Save the current sorting + */ + save: function () { + var store = this.options.store; + store && store.set(this); + }, + + + /** + * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree. + * @param {HTMLElement} el + * @param {String} [selector] default: `options.draggable` + * @returns {HTMLElement|null} + */ + closest: function (el, selector) { + return _closest(el, selector || this.options.draggable, this.el); + }, + + + /** + * Set/get option + * @param {string} name + * @param {*} [value] + * @returns {*} + */ + option: function (name, value) { + var options = this.options; + + if (value === void 0) { + return options[name]; + } else { + options[name] = value; + + if (name === 'group') { + _prepareGroup(options); + } + } + }, + + + /** + * Destroy + */ + destroy: function () { + var el = this.el; + + el[expando] = null; + + _off(el, 'mousedown', this._onTapStart); + _off(el, 'touchstart', this._onTapStart); + + if (this.nativeDraggable) { + _off(el, 'dragover', this); + _off(el, 'dragenter', this); + } + + // Remove draggable attributes + Array.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) { + el.removeAttribute('draggable'); + }); + + touchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1); + + this._onDrop(); + + this.el = el = null; + } + }; + + + function _cloneHide(state) { + if (cloneEl && (cloneEl.state !== state)) { + _css(cloneEl, 'display', state ? 'none' : ''); + !state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl); + cloneEl.state = state; + } + } + + + function _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) { + if (el) { + ctx = ctx || document; + + do { + if ((selector === '>*' && el.parentNode === ctx) || _matches(el, selector)) { + return el; + } + } + while (el !== ctx && (el = el.parentNode)); + } + + return null; + } + + + function _globalDragOver(/**Event*/evt) { + if (evt.dataTransfer) { + evt.dataTransfer.dropEffect = 'move'; + } + evt.preventDefault(); + } + + + function _on(el, event, fn) { + el.addEventListener(event, fn, false); + } + + + function _off(el, event, fn) { + el.removeEventListener(event, fn, false); + } + + + function _toggleClass(el, name, state) { + if (el) { + if (el.classList) { + el.classList[state ? 'add' : 'remove'](name); + } + else { + var className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' '); + el.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' '); + } + } + } + + + function _css(el, prop, val) { + var style = el && el.style; + + if (style) { + if (val === void 0) { + if (document.defaultView && document.defaultView.getComputedStyle) { + val = document.defaultView.getComputedStyle(el, ''); + } + else if (el.currentStyle) { + val = el.currentStyle; + } + + return prop === void 0 ? val : val[prop]; + } + else { + if (!(prop in style)) { + prop = '-webkit-' + prop; + } + + style[prop] = val + (typeof val === 'string' ? '' : 'px'); + } + } + } + + + function _find(ctx, tagName, iterator) { + if (ctx) { + var list = ctx.getElementsByTagName(tagName), i = 0, n = list.length; + + if (iterator) { + for (; i < n; i++) { + iterator(list[i], i); + } + } + + return list; + } + + return []; + } + + + + function _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) { + var evt = document.createEvent('Event'), + options = (sortable || rootEl[expando]).options, + onName = 'on' + name.charAt(0).toUpperCase() + name.substr(1); + + evt.initEvent(name, true, true); + + evt.to = rootEl; + evt.from = fromEl || rootEl; + evt.item = targetEl || rootEl; + evt.clone = cloneEl; + + evt.oldIndex = startIndex; + evt.newIndex = newIndex; + + rootEl.dispatchEvent(evt); + + if (options[onName]) { + options[onName].call(sortable, evt); + } + } + + + function _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) { + var evt, + sortable = fromEl[expando], + onMoveFn = sortable.options.onMove, + retVal; + + evt = document.createEvent('Event'); + evt.initEvent('move', true, true); + + evt.to = toEl; + evt.from = fromEl; + evt.dragged = dragEl; + evt.draggedRect = dragRect; + evt.related = targetEl || toEl; + evt.relatedRect = targetRect || toEl.getBoundingClientRect(); + + fromEl.dispatchEvent(evt); + + if (onMoveFn) { + retVal = onMoveFn.call(sortable, evt); + } + + return retVal; + } + + + function _disableDraggable(el) { + el.draggable = false; + } + + + function _unsilent() { + _silent = false; + } + + + /** @returns {HTMLElement|false} */ + function _ghostIsLast(el, evt) { + var lastEl = el.lastElementChild, + rect = lastEl.getBoundingClientRect(); + + return ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta + } + + + /** + * Generate id + * @param {HTMLElement} el + * @returns {String} + * @private + */ + function _generateId(el) { + var str = el.tagName + el.className + el.src + el.href + el.textContent, + i = str.length, + sum = 0; + + while (i--) { + sum += str.charCodeAt(i); + } + + return sum.toString(36); + } + + /** + * Returns the index of an element within its parent for a selected set of + * elements + * @param {HTMLElement} el + * @param {selector} selector + * @return {number} + */ + function _index(el, selector) { + var index = 0; + + if (!el || !el.parentNode) { + return -1; + } + + while (el && (el = el.previousElementSibling)) { + if ((el.nodeName.toUpperCase() !== 'TEMPLATE') && (selector === '>*' || _matches(el, selector))) { + index++; + } + } + + return index; + } + + function _matches(/**HTMLElement*/el, /**String*/selector) { + if (el) { + selector = selector.split('.'); + + var tag = selector.shift().toUpperCase(), + re = new RegExp('\\s(' + selector.join('|') + ')(?=\\s)', 'g'); + + return ( + (tag === '' || el.nodeName.toUpperCase() == tag) && + (!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length) + ); + } + + return false; + } + + function _throttle(callback, ms) { + var args, _this; + + return function () { + if (args === void 0) { + args = arguments; + _this = this; + + setTimeout(function () { + if (args.length === 1) { + callback.call(_this, args[0]); + } else { + callback.apply(_this, args); + } + + args = void 0; + }, ms); + } + }; + } + + function _extend(dst, src) { + if (dst && src) { + for (var key in src) { + if (src.hasOwnProperty(key)) { + dst[key] = src[key]; + } + } + } + + return dst; + } + + + // Export utils + Sortable.utils = { + on: _on, + off: _off, + css: _css, + find: _find, + is: function (el, selector) { + return !!_closest(el, selector, el); + }, + extend: _extend, + throttle: _throttle, + closest: _closest, + toggleClass: _toggleClass, + index: _index + }; + + + /** + * Create sortable instance + * @param {HTMLElement} el + * @param {Object} [options] + */ + Sortable.create = function (el, options) { + return new Sortable(el, options); + }; + + + // Export + Sortable.version = '1.4.2'; + return Sortable; +}); diff --git a/vendor/assets/javascripts/autosize.js b/vendor/assets/javascripts/autosize.js index cfa49e72c50..cfa49e72c50 100755..100644 --- a/vendor/assets/javascripts/autosize.js +++ b/vendor/assets/javascripts/autosize.js diff --git a/vendor/assets/javascripts/clipboard.js b/vendor/assets/javascripts/clipboard.js index 1b1f4f0bd63..39d7d2306f8 100644 --- a/vendor/assets/javascripts/clipboard.js +++ b/vendor/assets/javascripts/clipboard.js @@ -154,12 +154,12 @@ function E () { E.prototype = { on: function (name, callback, ctx) { var e = this.e || (this.e = {}); - + (e[name] || (e[name] = [])).push({ fn: callback, ctx: ctx }); - + return this; }, @@ -169,7 +169,7 @@ E.prototype = { self.off(name, fn); callback.apply(ctx, arguments); }; - + return this.on(name, fn, ctx); }, @@ -178,11 +178,11 @@ E.prototype = { var evtArr = ((this.e || (this.e = {}))[name] || []).slice(); var i = 0; var len = evtArr.length; - + for (i; i < len; i++) { evtArr[i].fn.apply(evtArr[i].ctx, data); } - + return this; }, @@ -190,21 +190,21 @@ E.prototype = { var e = this.e || (this.e = {}); var evts = e[name]; var liveEvents = []; - + if (evts && callback) { for (var i = 0, len = evts.length; i < len; i++) { if (evts[i].fn !== callback) liveEvents.push(evts[i]); } } - + // Remove event from queue to prevent memory leak // Suggested by https://github.com/lazd // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910 - (liveEvents.length) + (liveEvents.length) ? e[name] = liveEvents : delete e[name]; - + return this; } }; @@ -618,4 +618,4 @@ exports['default'] = Clipboard; module.exports = exports['default']; },{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7) -});
\ No newline at end of file +}); diff --git a/vendor/assets/javascripts/jquery.scrollTo.js b/vendor/assets/javascripts/jquery.scrollTo.js index 7ba17766b70..7ba17766b70 100755..100644 --- a/vendor/assets/javascripts/jquery.scrollTo.js +++ b/vendor/assets/javascripts/jquery.scrollTo.js diff --git a/vendor/assets/javascripts/task_list.js b/vendor/assets/javascripts/task_list.js index bc451506b6a..9fbfef03f6d 100644 --- a/vendor/assets/javascripts/task_list.js +++ b/vendor/assets/javascripts/task_list.js @@ -1,15 +1,118 @@ - +// The MIT License (MIT) +// +// Copyright (c) 2014 GitHub, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// TaskList Behavior +// /*= provides tasklist:enabled */ - - /*= provides tasklist:disabled */ - - /*= provides tasklist:change */ - - /*= provides tasklist:changed */ - +// +// +// Enables Task List update behavior. +// +// ### Example Markup +// +// <div class="js-task-list-container"> +// <ul class="task-list"> +// <li class="task-list-item"> +// <input type="checkbox" class="js-task-list-item-checkbox" disabled /> +// text +// </li> +// </ul> +// <form> +// <textarea class="js-task-list-field">- [ ] text</textarea> +// </form> +// </div> +// +// ### Specification +// +// TaskLists MUST be contained in a `(div).js-task-list-container`. +// +// TaskList Items SHOULD be an a list (`UL`/`OL`) element. +// +// Task list items MUST match `(input).task-list-item-checkbox` and MUST be +// `disabled` by default. +// +// TaskLists MUST have a `(textarea).js-task-list-field` form element whose +// `value` attribute is the source (Markdown) to be udpated. The source MUST +// follow the syntax guidelines. +// +// TaskList updates trigger `tasklist:change` events. If the change is +// successful, `tasklist:changed` is fired. The change can be canceled. +// +// jQuery is required. +// +// ### Methods +// +// `.taskList('enable')` or `.taskList()` +// +// Enables TaskList updates for the container. +// +// `.taskList('disable')` +// +// Disables TaskList updates for the container. +// +//# ### Events +// +// `tasklist:enabled` +// +// Fired when the TaskList is enabled. +// +// * **Synchronicity** Sync +// * **Bubbles** Yes +// * **Cancelable** No +// * **Target** `.js-task-list-container` +// +// `tasklist:disabled` +// +// Fired when the TaskList is disabled. +// +// * **Synchronicity** Sync +// * **Bubbles** Yes +// * **Cancelable** No +// * **Target** `.js-task-list-container` +// +// `tasklist:change` +// +// Fired before the TaskList item change takes affect. +// +// * **Synchronicity** Sync +// * **Bubbles** Yes +// * **Cancelable** Yes +// * **Target** `.js-task-list-field` +// +// `tasklist:changed` +// +// Fired once the TaskList item change has taken affect. +// +// * **Synchronicity** Sync +// * **Bubbles** Yes +// * **Cancelable** No +// * **Target** `.js-task-list-field` +// +// ### NOTE +// +// Task list checkboxes are rendered as disabled by default because rendered +// user content is cached without regard for the viewer. (function() { var codeFencesPattern, complete, completePattern, disableTaskList, disableTaskLists, enableTaskList, enableTaskLists, escapePattern, incomplete, incompletePattern, itemPattern, itemsInParasPattern, updateTaskList, updateTaskListItem, indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; @@ -18,20 +121,48 @@ complete = "[x]"; + // Escapes the String for regular expression matching. escapePattern = function(str) { return str.replace(/([\[\]])/g, "\\$1").replace(/\s/, "\\s").replace("x", "[xX]"); }; - incompletePattern = RegExp("" + (escapePattern(incomplete))); - - completePattern = RegExp("" + (escapePattern(complete))); + incompletePattern = RegExp("" + (escapePattern(incomplete))); // escape square brackets + // match all white space + completePattern = RegExp("" + (escapePattern(complete))); // match all cases + // Pattern used to identify all task list items. + // Useful when you need iterate over all items. itemPattern = RegExp("^(?:\\s*(?:>\\s*)*(?:[-+*]|(?:\\d+\\.)))\\s*(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ")\\s+(?!\\(.*?\\))(?=(?:\\[.*?\\]\\s*(?:\\[.*?\\]|\\(.*?\\))\\s*)*(?:[^\\[]|$))"); + // prefix, consisting of + // optional leading whitespace + // zero or more blockquotes + // list item indicator + // optional whitespace prefix + // checkbox + // is followed by whitespace + // is not part of a [foo](url) link + // and is followed by zero or more links + // and either a non-link or the end of the string + // Used to filter out code fences from the source for comparison only. + // http://rubular.com/r/x5EwZVrloI + // Modified slightly due to issues with JS codeFencesPattern = /^`{3}(?:\s*\w+)?[\S\s].*[\S\s]^`{3}$/mg; + // ``` + // followed by optional language + // whitespace + // code + // whitespace + // ``` + // Used to filter out potential mismatches (items not in lists). + // http://rubular.com/r/OInl6CiePy itemsInParasPattern = RegExp("^(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ").+$", "g"); + // Given the source text, updates the appropriate task list item to match the + // given checked value. + // + // Returns the updated String text. updateTaskListItem = function(source, itemIndex, checked) { var clean, index, line, result; clean = source.replace(/\r/g, '').replace(codeFencesPattern, '').replace(itemsInParasPattern, '').split("\n"); @@ -55,6 +186,9 @@ return result.join("\n"); }; + // Updates the $field value to reflect the state of $item. + // Triggers the `tasklist:change` event before the value has changed, and fires + // a `tasklist:changed` event once the value has changed. updateTaskList = function($item) { var $container, $field, checked, event, index; $container = $item.closest('.js-task-list-container'); @@ -70,10 +204,12 @@ } }; + // When the task list item checkbox is updated, submit the change $(document).on('change', '.task-list-item-checkbox', function() { return updateTaskList($(this)); }); + // Enables TaskList item changes. enableTaskList = function($container) { if ($container.find('.js-task-list-field').length > 0) { $container.find('.task-list-item').addClass('enabled').find('.task-list-item-checkbox').attr('disabled', null); @@ -81,6 +217,7 @@ } }; + // Enables a collection of TaskList containers. enableTaskLists = function($containers) { var container, i, len, results; results = []; @@ -91,11 +228,13 @@ return results; }; + // Disable TaskList item changes. disableTaskList = function($container) { $container.find('.task-list-item').removeClass('enabled').find('.task-list-item-checkbox').attr('disabled', 'disabled'); return $container.removeClass('is-task-list-enabled').trigger('tasklist:disabled'); }; + // Disables a collection of TaskList containers. disableTaskLists = function($containers) { var container, i, len, results; results = []; diff --git a/vendor/assets/javascripts/vue-resource.full.js b/vendor/assets/javascripts/vue-resource.full.js new file mode 100644 index 00000000000..d7981dbec7e --- /dev/null +++ b/vendor/assets/javascripts/vue-resource.full.js @@ -0,0 +1,1318 @@ +/*! + * vue-resource v0.9.3 + * https://github.com/vuejs/vue-resource + * Released under the MIT License. + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.VueResource = factory()); +}(this, function () { 'use strict'; + + /** + * Promises/A+ polyfill v1.1.4 (https://github.com/bramstein/promis) + */ + + var RESOLVED = 0; + var REJECTED = 1; + var PENDING = 2; + + function Promise$2(executor) { + + this.state = PENDING; + this.value = undefined; + this.deferred = []; + + var promise = this; + + try { + executor(function (x) { + promise.resolve(x); + }, function (r) { + promise.reject(r); + }); + } catch (e) { + promise.reject(e); + } + } + + Promise$2.reject = function (r) { + return new Promise$2(function (resolve, reject) { + reject(r); + }); + }; + + Promise$2.resolve = function (x) { + return new Promise$2(function (resolve, reject) { + resolve(x); + }); + }; + + Promise$2.all = function all(iterable) { + return new Promise$2(function (resolve, reject) { + var count = 0, + result = []; + + if (iterable.length === 0) { + resolve(result); + } + + function resolver(i) { + return function (x) { + result[i] = x; + count += 1; + + if (count === iterable.length) { + resolve(result); + } + }; + } + + for (var i = 0; i < iterable.length; i += 1) { + Promise$2.resolve(iterable[i]).then(resolver(i), reject); + } + }); + }; + + Promise$2.race = function race(iterable) { + return new Promise$2(function (resolve, reject) { + for (var i = 0; i < iterable.length; i += 1) { + Promise$2.resolve(iterable[i]).then(resolve, reject); + } + }); + }; + + var p$1 = Promise$2.prototype; + + p$1.resolve = function resolve(x) { + var promise = this; + + if (promise.state === PENDING) { + if (x === promise) { + throw new TypeError('Promise settled with itself.'); + } + + var called = false; + + try { + var then = x && x['then']; + + if (x !== null && typeof x === 'object' && typeof then === 'function') { + then.call(x, function (x) { + if (!called) { + promise.resolve(x); + } + called = true; + }, function (r) { + if (!called) { + promise.reject(r); + } + called = true; + }); + return; + } + } catch (e) { + if (!called) { + promise.reject(e); + } + return; + } + + promise.state = RESOLVED; + promise.value = x; + promise.notify(); + } + }; + + p$1.reject = function reject(reason) { + var promise = this; + + if (promise.state === PENDING) { + if (reason === promise) { + throw new TypeError('Promise settled with itself.'); + } + + promise.state = REJECTED; + promise.value = reason; + promise.notify(); + } + }; + + p$1.notify = function notify() { + var promise = this; + + nextTick(function () { + if (promise.state !== PENDING) { + while (promise.deferred.length) { + var deferred = promise.deferred.shift(), + onResolved = deferred[0], + onRejected = deferred[1], + resolve = deferred[2], + reject = deferred[3]; + + try { + if (promise.state === RESOLVED) { + if (typeof onResolved === 'function') { + resolve(onResolved.call(undefined, promise.value)); + } else { + resolve(promise.value); + } + } else if (promise.state === REJECTED) { + if (typeof onRejected === 'function') { + resolve(onRejected.call(undefined, promise.value)); + } else { + reject(promise.value); + } + } + } catch (e) { + reject(e); + } + } + } + }); + }; + + p$1.then = function then(onResolved, onRejected) { + var promise = this; + + return new Promise$2(function (resolve, reject) { + promise.deferred.push([onResolved, onRejected, resolve, reject]); + promise.notify(); + }); + }; + + p$1.catch = function (onRejected) { + return this.then(undefined, onRejected); + }; + + var PromiseObj = window.Promise || Promise$2; + + function Promise$1(executor, context) { + + if (executor instanceof PromiseObj) { + this.promise = executor; + } else { + this.promise = new PromiseObj(executor.bind(context)); + } + + this.context = context; + } + + Promise$1.all = function (iterable, context) { + return new Promise$1(PromiseObj.all(iterable), context); + }; + + Promise$1.resolve = function (value, context) { + return new Promise$1(PromiseObj.resolve(value), context); + }; + + Promise$1.reject = function (reason, context) { + return new Promise$1(PromiseObj.reject(reason), context); + }; + + Promise$1.race = function (iterable, context) { + return new Promise$1(PromiseObj.race(iterable), context); + }; + + var p = Promise$1.prototype; + + p.bind = function (context) { + this.context = context; + return this; + }; + + p.then = function (fulfilled, rejected) { + + if (fulfilled && fulfilled.bind && this.context) { + fulfilled = fulfilled.bind(this.context); + } + + if (rejected && rejected.bind && this.context) { + rejected = rejected.bind(this.context); + } + + return new Promise$1(this.promise.then(fulfilled, rejected), this.context); + }; + + p.catch = function (rejected) { + + if (rejected && rejected.bind && this.context) { + rejected = rejected.bind(this.context); + } + + return new Promise$1(this.promise.catch(rejected), this.context); + }; + + p.finally = function (callback) { + + return this.then(function (value) { + callback.call(this); + return value; + }, function (reason) { + callback.call(this); + return PromiseObj.reject(reason); + }); + }; + + var debug = false; + var util = {}; + var array = []; + function Util (Vue) { + util = Vue.util; + debug = Vue.config.debug || !Vue.config.silent; + } + + function warn(msg) { + if (typeof console !== 'undefined' && debug) { + console.warn('[VueResource warn]: ' + msg); + } + } + + function error(msg) { + if (typeof console !== 'undefined') { + console.error(msg); + } + } + + function nextTick(cb, ctx) { + return util.nextTick(cb, ctx); + } + + function trim(str) { + return str.replace(/^\s*|\s*$/g, ''); + } + + var isArray = Array.isArray; + + function isString(val) { + return typeof val === 'string'; + } + + function isBoolean(val) { + return val === true || val === false; + } + + function isFunction(val) { + return typeof val === 'function'; + } + + function isObject(obj) { + return obj !== null && typeof obj === 'object'; + } + + function isPlainObject(obj) { + return isObject(obj) && Object.getPrototypeOf(obj) == Object.prototype; + } + + function isFormData(obj) { + return typeof FormData !== 'undefined' && obj instanceof FormData; + } + + function when(value, fulfilled, rejected) { + + var promise = Promise$1.resolve(value); + + if (arguments.length < 2) { + return promise; + } + + return promise.then(fulfilled, rejected); + } + + function options(fn, obj, opts) { + + opts = opts || {}; + + if (isFunction(opts)) { + opts = opts.call(obj); + } + + return merge(fn.bind({ $vm: obj, $options: opts }), fn, { $options: opts }); + } + + function each(obj, iterator) { + + var i, key; + + if (typeof obj.length == 'number') { + for (i = 0; i < obj.length; i++) { + iterator.call(obj[i], obj[i], i); + } + } else if (isObject(obj)) { + for (key in obj) { + if (obj.hasOwnProperty(key)) { + iterator.call(obj[key], obj[key], key); + } + } + } + + return obj; + } + + var assign = Object.assign || _assign; + + function merge(target) { + + var args = array.slice.call(arguments, 1); + + args.forEach(function (source) { + _merge(target, source, true); + }); + + return target; + } + + function defaults(target) { + + var args = array.slice.call(arguments, 1); + + args.forEach(function (source) { + + for (var key in source) { + if (target[key] === undefined) { + target[key] = source[key]; + } + } + }); + + return target; + } + + function _assign(target) { + + var args = array.slice.call(arguments, 1); + + args.forEach(function (source) { + _merge(target, source); + }); + + return target; + } + + function _merge(target, source, deep) { + for (var key in source) { + if (deep && (isPlainObject(source[key]) || isArray(source[key]))) { + if (isPlainObject(source[key]) && !isPlainObject(target[key])) { + target[key] = {}; + } + if (isArray(source[key]) && !isArray(target[key])) { + target[key] = []; + } + _merge(target[key], source[key], deep); + } else if (source[key] !== undefined) { + target[key] = source[key]; + } + } + } + + function root (options, next) { + + var url = next(options); + + if (isString(options.root) && !url.match(/^(https?:)?\//)) { + url = options.root + '/' + url; + } + + return url; + } + + function query (options, next) { + + var urlParams = Object.keys(Url.options.params), + query = {}, + url = next(options); + + each(options.params, function (value, key) { + if (urlParams.indexOf(key) === -1) { + query[key] = value; + } + }); + + query = Url.params(query); + + if (query) { + url += (url.indexOf('?') == -1 ? '?' : '&') + query; + } + + return url; + } + + /** + * URL Template v2.0.6 (https://github.com/bramstein/url-template) + */ + + function expand(url, params, variables) { + + var tmpl = parse(url), + expanded = tmpl.expand(params); + + if (variables) { + variables.push.apply(variables, tmpl.vars); + } + + return expanded; + } + + function parse(template) { + + var operators = ['+', '#', '.', '/', ';', '?', '&'], + variables = []; + + return { + vars: variables, + expand: function (context) { + return template.replace(/\{([^\{\}]+)\}|([^\{\}]+)/g, function (_, expression, literal) { + if (expression) { + + var operator = null, + values = []; + + if (operators.indexOf(expression.charAt(0)) !== -1) { + operator = expression.charAt(0); + expression = expression.substr(1); + } + + expression.split(/,/g).forEach(function (variable) { + var tmp = /([^:\*]*)(?::(\d+)|(\*))?/.exec(variable); + values.push.apply(values, getValues(context, operator, tmp[1], tmp[2] || tmp[3])); + variables.push(tmp[1]); + }); + + if (operator && operator !== '+') { + + var separator = ','; + + if (operator === '?') { + separator = '&'; + } else if (operator !== '#') { + separator = operator; + } + + return (values.length !== 0 ? operator : '') + values.join(separator); + } else { + return values.join(','); + } + } else { + return encodeReserved(literal); + } + }); + } + }; + } + + function getValues(context, operator, key, modifier) { + + var value = context[key], + result = []; + + if (isDefined(value) && value !== '') { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + value = value.toString(); + + if (modifier && modifier !== '*') { + value = value.substring(0, parseInt(modifier, 10)); + } + + result.push(encodeValue(operator, value, isKeyOperator(operator) ? key : null)); + } else { + if (modifier === '*') { + if (Array.isArray(value)) { + value.filter(isDefined).forEach(function (value) { + result.push(encodeValue(operator, value, isKeyOperator(operator) ? key : null)); + }); + } else { + Object.keys(value).forEach(function (k) { + if (isDefined(value[k])) { + result.push(encodeValue(operator, value[k], k)); + } + }); + } + } else { + var tmp = []; + + if (Array.isArray(value)) { + value.filter(isDefined).forEach(function (value) { + tmp.push(encodeValue(operator, value)); + }); + } else { + Object.keys(value).forEach(function (k) { + if (isDefined(value[k])) { + tmp.push(encodeURIComponent(k)); + tmp.push(encodeValue(operator, value[k].toString())); + } + }); + } + + if (isKeyOperator(operator)) { + result.push(encodeURIComponent(key) + '=' + tmp.join(',')); + } else if (tmp.length !== 0) { + result.push(tmp.join(',')); + } + } + } + } else { + if (operator === ';') { + result.push(encodeURIComponent(key)); + } else if (value === '' && (operator === '&' || operator === '?')) { + result.push(encodeURIComponent(key) + '='); + } else if (value === '') { + result.push(''); + } + } + + return result; + } + + function isDefined(value) { + return value !== undefined && value !== null; + } + + function isKeyOperator(operator) { + return operator === ';' || operator === '&' || operator === '?'; + } + + function encodeValue(operator, value, key) { + + value = operator === '+' || operator === '#' ? encodeReserved(value) : encodeURIComponent(value); + + if (key) { + return encodeURIComponent(key) + '=' + value; + } else { + return value; + } + } + + function encodeReserved(str) { + return str.split(/(%[0-9A-Fa-f]{2})/g).map(function (part) { + if (!/%[0-9A-Fa-f]/.test(part)) { + part = encodeURI(part); + } + return part; + }).join(''); + } + + function template (options) { + + var variables = [], + url = expand(options.url, options.params, variables); + + variables.forEach(function (key) { + delete options.params[key]; + }); + + return url; + } + + /** + * Service for URL templating. + */ + + var ie = document.documentMode; + var el = document.createElement('a'); + + function Url(url, params) { + + var self = this || {}, + options = url, + transform; + + if (isString(url)) { + options = { url: url, params: params }; + } + + options = merge({}, Url.options, self.$options, options); + + Url.transforms.forEach(function (handler) { + transform = factory(handler, transform, self.$vm); + }); + + return transform(options); + } + + /** + * Url options. + */ + + Url.options = { + url: '', + root: null, + params: {} + }; + + /** + * Url transforms. + */ + + Url.transforms = [template, query, root]; + + /** + * Encodes a Url parameter string. + * + * @param {Object} obj + */ + + Url.params = function (obj) { + + var params = [], + escape = encodeURIComponent; + + params.add = function (key, value) { + + if (isFunction(value)) { + value = value(); + } + + if (value === null) { + value = ''; + } + + this.push(escape(key) + '=' + escape(value)); + }; + + serialize(params, obj); + + return params.join('&').replace(/%20/g, '+'); + }; + + /** + * Parse a URL and return its components. + * + * @param {String} url + */ + + Url.parse = function (url) { + + if (ie) { + el.href = url; + url = el.href; + } + + el.href = url; + + return { + href: el.href, + protocol: el.protocol ? el.protocol.replace(/:$/, '') : '', + port: el.port, + host: el.host, + hostname: el.hostname, + pathname: el.pathname.charAt(0) === '/' ? el.pathname : '/' + el.pathname, + search: el.search ? el.search.replace(/^\?/, '') : '', + hash: el.hash ? el.hash.replace(/^#/, '') : '' + }; + }; + + function factory(handler, next, vm) { + return function (options) { + return handler.call(vm, options, next); + }; + } + + function serialize(params, obj, scope) { + + var array = isArray(obj), + plain = isPlainObject(obj), + hash; + + each(obj, function (value, key) { + + hash = isObject(value) || isArray(value); + + if (scope) { + key = scope + '[' + (plain || hash ? key : '') + ']'; + } + + if (!scope && array) { + params.add(value.name, value.value); + } else if (hash) { + serialize(params, value, key); + } else { + params.add(key, value); + } + }); + } + + function xdrClient (request) { + return new Promise$1(function (resolve) { + + var xdr = new XDomainRequest(), + handler = function (event) { + + var response = request.respondWith(xdr.responseText, { + status: xdr.status, + statusText: xdr.statusText + }); + + resolve(response); + }; + + request.abort = function () { + return xdr.abort(); + }; + + xdr.open(request.method, request.getUrl(), true); + xdr.timeout = 0; + xdr.onload = handler; + xdr.onerror = handler; + xdr.ontimeout = function () {}; + xdr.onprogress = function () {}; + xdr.send(request.getBody()); + }); + } + + var ORIGIN_URL = Url.parse(location.href); + var SUPPORTS_CORS = 'withCredentials' in new XMLHttpRequest(); + + function cors (request, next) { + + if (!isBoolean(request.crossOrigin) && crossOrigin(request)) { + request.crossOrigin = true; + } + + if (request.crossOrigin) { + + if (!SUPPORTS_CORS) { + request.client = xdrClient; + } + + delete request.emulateHTTP; + } + + next(); + } + + function crossOrigin(request) { + + var requestUrl = Url.parse(Url(request)); + + return requestUrl.protocol !== ORIGIN_URL.protocol || requestUrl.host !== ORIGIN_URL.host; + } + + function body (request, next) { + + if (request.emulateJSON && isPlainObject(request.body)) { + request.body = Url.params(request.body); + request.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + if (isFormData(request.body)) { + delete request.headers['Content-Type']; + } + + if (isPlainObject(request.body)) { + request.body = JSON.stringify(request.body); + } + + next(function (response) { + + var contentType = response.headers['Content-Type']; + + if (isString(contentType) && contentType.indexOf('application/json') === 0) { + + try { + response.data = response.json(); + } catch (e) { + response.data = null; + } + } else { + response.data = response.text(); + } + }); + } + + function jsonpClient (request) { + return new Promise$1(function (resolve) { + + var name = request.jsonp || 'callback', + callback = '_jsonp' + Math.random().toString(36).substr(2), + body = null, + handler, + script; + + handler = function (event) { + + var status = 0; + + if (event.type === 'load' && body !== null) { + status = 200; + } else if (event.type === 'error') { + status = 404; + } + + resolve(request.respondWith(body, { status: status })); + + delete window[callback]; + document.body.removeChild(script); + }; + + request.params[name] = callback; + + window[callback] = function (result) { + body = JSON.stringify(result); + }; + + script = document.createElement('script'); + script.src = request.getUrl(); + script.type = 'text/javascript'; + script.async = true; + script.onload = handler; + script.onerror = handler; + + document.body.appendChild(script); + }); + } + + function jsonp (request, next) { + + if (request.method == 'JSONP') { + request.client = jsonpClient; + } + + next(function (response) { + + if (request.method == 'JSONP') { + response.data = response.json(); + } + }); + } + + function before (request, next) { + + if (isFunction(request.before)) { + request.before.call(this, request); + } + + next(); + } + + /** + * HTTP method override Interceptor. + */ + + function method (request, next) { + + if (request.emulateHTTP && /^(PUT|PATCH|DELETE)$/i.test(request.method)) { + request.headers['X-HTTP-Method-Override'] = request.method; + request.method = 'POST'; + } + + next(); + } + + function header (request, next) { + + request.method = request.method.toUpperCase(); + request.headers = assign({}, Http.headers.common, !request.crossOrigin ? Http.headers.custom : {}, Http.headers[request.method.toLowerCase()], request.headers); + + next(); + } + + /** + * Timeout Interceptor. + */ + + function timeout (request, next) { + + var timeout; + + if (request.timeout) { + timeout = setTimeout(function () { + request.abort(); + }, request.timeout); + } + + next(function (response) { + + clearTimeout(timeout); + }); + } + + function xhrClient (request) { + return new Promise$1(function (resolve) { + + var xhr = new XMLHttpRequest(), + handler = function (event) { + + var response = request.respondWith('response' in xhr ? xhr.response : xhr.responseText, { + status: xhr.status === 1223 ? 204 : xhr.status, // IE9 status bug + statusText: xhr.status === 1223 ? 'No Content' : trim(xhr.statusText), + headers: parseHeaders(xhr.getAllResponseHeaders()) + }); + + resolve(response); + }; + + request.abort = function () { + return xhr.abort(); + }; + + xhr.open(request.method, request.getUrl(), true); + xhr.timeout = 0; + xhr.onload = handler; + xhr.onerror = handler; + + if (request.progress) { + if (request.method === 'GET') { + xhr.addEventListener('progress', request.progress); + } else if (/^(POST|PUT)$/i.test(request.method)) { + xhr.upload.addEventListener('progress', request.progress); + } + } + + if (request.credentials === true) { + xhr.withCredentials = true; + } + + each(request.headers || {}, function (value, header) { + xhr.setRequestHeader(header, value); + }); + + xhr.send(request.getBody()); + }); + } + + function parseHeaders(str) { + + var headers = {}, + value, + name, + i; + + each(trim(str).split('\n'), function (row) { + + i = row.indexOf(':'); + name = trim(row.slice(0, i)); + value = trim(row.slice(i + 1)); + + if (headers[name]) { + + if (isArray(headers[name])) { + headers[name].push(value); + } else { + headers[name] = [headers[name], value]; + } + } else { + + headers[name] = value; + } + }); + + return headers; + } + + function Client (context) { + + var reqHandlers = [sendRequest], + resHandlers = [], + handler; + + if (!isObject(context)) { + context = null; + } + + function Client(request) { + return new Promise$1(function (resolve) { + + function exec() { + + handler = reqHandlers.pop(); + + if (isFunction(handler)) { + handler.call(context, request, next); + } else { + warn('Invalid interceptor of type ' + typeof handler + ', must be a function'); + next(); + } + } + + function next(response) { + + if (isFunction(response)) { + + resHandlers.unshift(response); + } else if (isObject(response)) { + + resHandlers.forEach(function (handler) { + response = when(response, function (response) { + return handler.call(context, response) || response; + }); + }); + + when(response, resolve); + + return; + } + + exec(); + } + + exec(); + }, context); + } + + Client.use = function (handler) { + reqHandlers.push(handler); + }; + + return Client; + } + + function sendRequest(request, resolve) { + + var client = request.client || xhrClient; + + resolve(client(request)); + } + + var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + }; + + /** + * HTTP Response. + */ + + var Response = function () { + function Response(body, _ref) { + var url = _ref.url; + var headers = _ref.headers; + var status = _ref.status; + var statusText = _ref.statusText; + classCallCheck(this, Response); + + + this.url = url; + this.body = body; + this.headers = headers || {}; + this.status = status || 0; + this.statusText = statusText || ''; + this.ok = status >= 200 && status < 300; + } + + Response.prototype.text = function text() { + return this.body; + }; + + Response.prototype.blob = function blob() { + return new Blob([this.body]); + }; + + Response.prototype.json = function json() { + return JSON.parse(this.body); + }; + + return Response; + }(); + + var Request = function () { + function Request(options) { + classCallCheck(this, Request); + + + this.method = 'GET'; + this.body = null; + this.params = {}; + this.headers = {}; + + assign(this, options); + } + + Request.prototype.getUrl = function getUrl() { + return Url(this); + }; + + Request.prototype.getBody = function getBody() { + return this.body; + }; + + Request.prototype.respondWith = function respondWith(body, options) { + return new Response(body, assign(options || {}, { url: this.getUrl() })); + }; + + return Request; + }(); + + /** + * Service for sending network requests. + */ + + var CUSTOM_HEADERS = { 'X-Requested-With': 'XMLHttpRequest' }; + var COMMON_HEADERS = { 'Accept': 'application/json, text/plain, */*' }; + var JSON_CONTENT_TYPE = { 'Content-Type': 'application/json;charset=utf-8' }; + + function Http(options) { + + var self = this || {}, + client = Client(self.$vm); + + defaults(options || {}, self.$options, Http.options); + + Http.interceptors.forEach(function (handler) { + client.use(handler); + }); + + return client(new Request(options)).then(function (response) { + + return response.ok ? response : Promise$1.reject(response); + }, function (response) { + + if (response instanceof Error) { + error(response); + } + + return Promise$1.reject(response); + }); + } + + Http.options = {}; + + Http.headers = { + put: JSON_CONTENT_TYPE, + post: JSON_CONTENT_TYPE, + patch: JSON_CONTENT_TYPE, + delete: JSON_CONTENT_TYPE, + custom: CUSTOM_HEADERS, + common: COMMON_HEADERS + }; + + Http.interceptors = [before, timeout, method, body, jsonp, header, cors]; + + ['get', 'delete', 'head', 'jsonp'].forEach(function (method) { + + Http[method] = function (url, options) { + return this(assign(options || {}, { url: url, method: method })); + }; + }); + + ['post', 'put', 'patch'].forEach(function (method) { + + Http[method] = function (url, body, options) { + return this(assign(options || {}, { url: url, method: method, body: body })); + }; + }); + + function Resource(url, params, actions, options) { + + var self = this || {}, + resource = {}; + + actions = assign({}, Resource.actions, actions); + + each(actions, function (action, name) { + + action = merge({ url: url, params: params || {} }, options, action); + + resource[name] = function () { + return (self.$http || Http)(opts(action, arguments)); + }; + }); + + return resource; + } + + function opts(action, args) { + + var options = assign({}, action), + params = {}, + body; + + switch (args.length) { + + case 2: + + params = args[0]; + body = args[1]; + + break; + + case 1: + + if (/^(POST|PUT|PATCH)$/i.test(options.method)) { + body = args[0]; + } else { + params = args[0]; + } + + break; + + case 0: + + break; + + default: + + throw 'Expected up to 4 arguments [params, body], got ' + args.length + ' arguments'; + } + + options.body = body; + options.params = assign({}, options.params, params); + + return options; + } + + Resource.actions = { + + get: { method: 'GET' }, + save: { method: 'POST' }, + query: { method: 'GET' }, + update: { method: 'PUT' }, + remove: { method: 'DELETE' }, + delete: { method: 'DELETE' } + + }; + + function plugin(Vue) { + + if (plugin.installed) { + return; + } + + Util(Vue); + + Vue.url = Url; + Vue.http = Http; + Vue.resource = Resource; + Vue.Promise = Promise$1; + + Object.defineProperties(Vue.prototype, { + + $url: { + get: function () { + return options(Vue.url, this, this.$options.url); + } + }, + + $http: { + get: function () { + return options(Vue.http, this, this.$options.http); + } + }, + + $resource: { + get: function () { + return Vue.resource.bind(this); + } + }, + + $promise: { + get: function () { + var _this = this; + + return function (executor) { + return new Vue.Promise(executor, _this); + }; + } + } + + }); + } + + if (typeof window !== 'undefined' && window.Vue) { + window.Vue.use(plugin); + } + + return plugin; + +}));
\ No newline at end of file diff --git a/vendor/assets/javascripts/vue-resource.js.erb b/vendor/assets/javascripts/vue-resource.js.erb new file mode 100644 index 00000000000..8001775ce98 --- /dev/null +++ b/vendor/assets/javascripts/vue-resource.js.erb @@ -0,0 +1,2 @@ +<% type = Rails.env.development? ? 'full' : 'min' %> +<%= File.read(Rails.root.join("vendor/assets/javascripts/vue-resource.#{type}.js")) %> diff --git a/vendor/assets/javascripts/vue-resource.min.js b/vendor/assets/javascripts/vue-resource.min.js new file mode 100644 index 00000000000..6bff73a2a67 --- /dev/null +++ b/vendor/assets/javascripts/vue-resource.min.js @@ -0,0 +1,7 @@ +/*! + * vue-resource v0.9.3 + * https://github.com/vuejs/vue-resource + * Released under the MIT License. + */ + +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):t.VueResource=n()}(this,function(){"use strict";function t(t){this.state=Z,this.value=void 0,this.deferred=[];var n=this;try{t(function(t){n.resolve(t)},function(t){n.reject(t)})}catch(e){n.reject(e)}}function n(t,n){t instanceof nt?this.promise=t:this.promise=new nt(t.bind(n)),this.context=n}function e(t){rt=t.util,ot=t.config.debug||!t.config.silent}function o(t){"undefined"!=typeof console&&ot&&console.warn("[VueResource warn]: "+t)}function r(t){"undefined"!=typeof console&&console.error(t)}function i(t,n){return rt.nextTick(t,n)}function u(t){return t.replace(/^\s*|\s*$/g,"")}function s(t){return"string"==typeof t}function c(t){return t===!0||t===!1}function a(t){return"function"==typeof t}function f(t){return null!==t&&"object"==typeof t}function h(t){return f(t)&&Object.getPrototypeOf(t)==Object.prototype}function p(t){return"undefined"!=typeof FormData&&t instanceof FormData}function l(t,e,o){var r=n.resolve(t);return arguments.length<2?r:r.then(e,o)}function d(t,n,e){return e=e||{},a(e)&&(e=e.call(n)),v(t.bind({$vm:n,$options:e}),t,{$options:e})}function m(t,n){var e,o;if("number"==typeof t.length)for(e=0;e<t.length;e++)n.call(t[e],t[e],e);else if(f(t))for(o in t)t.hasOwnProperty(o)&&n.call(t[o],t[o],o);return t}function v(t){var n=it.slice.call(arguments,1);return n.forEach(function(n){g(t,n,!0)}),t}function y(t){var n=it.slice.call(arguments,1);return n.forEach(function(n){for(var e in n)void 0===t[e]&&(t[e]=n[e])}),t}function b(t){var n=it.slice.call(arguments,1);return n.forEach(function(n){g(t,n)}),t}function g(t,n,e){for(var o in n)e&&(h(n[o])||ut(n[o]))?(h(n[o])&&!h(t[o])&&(t[o]={}),ut(n[o])&&!ut(t[o])&&(t[o]=[]),g(t[o],n[o],e)):void 0!==n[o]&&(t[o]=n[o])}function w(t,n){var e=n(t);return s(t.root)&&!e.match(/^(https?:)?\//)&&(e=t.root+"/"+e),e}function T(t,n){var e=Object.keys(R.options.params),o={},r=n(t);return m(t.params,function(t,n){e.indexOf(n)===-1&&(o[n]=t)}),o=R.params(o),o&&(r+=(r.indexOf("?")==-1?"?":"&")+o),r}function j(t,n,e){var o=E(t),r=o.expand(n);return e&&e.push.apply(e,o.vars),r}function E(t){var n=["+","#",".","/",";","?","&"],e=[];return{vars:e,expand:function(o){return t.replace(/\{([^\{\}]+)\}|([^\{\}]+)/g,function(t,r,i){if(r){var u=null,s=[];if(n.indexOf(r.charAt(0))!==-1&&(u=r.charAt(0),r=r.substr(1)),r.split(/,/g).forEach(function(t){var n=/([^:\*]*)(?::(\d+)|(\*))?/.exec(t);s.push.apply(s,x(o,u,n[1],n[2]||n[3])),e.push(n[1])}),u&&"+"!==u){var c=",";return"?"===u?c="&":"#"!==u&&(c=u),(0!==s.length?u:"")+s.join(c)}return s.join(",")}return $(i)})}}}function x(t,n,e,o){var r=t[e],i=[];if(O(r)&&""!==r)if("string"==typeof r||"number"==typeof r||"boolean"==typeof r)r=r.toString(),o&&"*"!==o&&(r=r.substring(0,parseInt(o,10))),i.push(C(n,r,P(n)?e:null));else if("*"===o)Array.isArray(r)?r.filter(O).forEach(function(t){i.push(C(n,t,P(n)?e:null))}):Object.keys(r).forEach(function(t){O(r[t])&&i.push(C(n,r[t],t))});else{var u=[];Array.isArray(r)?r.filter(O).forEach(function(t){u.push(C(n,t))}):Object.keys(r).forEach(function(t){O(r[t])&&(u.push(encodeURIComponent(t)),u.push(C(n,r[t].toString())))}),P(n)?i.push(encodeURIComponent(e)+"="+u.join(",")):0!==u.length&&i.push(u.join(","))}else";"===n?i.push(encodeURIComponent(e)):""!==r||"&"!==n&&"?"!==n?""===r&&i.push(""):i.push(encodeURIComponent(e)+"=");return i}function O(t){return void 0!==t&&null!==t}function P(t){return";"===t||"&"===t||"?"===t}function C(t,n,e){return n="+"===t||"#"===t?$(n):encodeURIComponent(n),e?encodeURIComponent(e)+"="+n:n}function $(t){return t.split(/(%[0-9A-Fa-f]{2})/g).map(function(t){return/%[0-9A-Fa-f]/.test(t)||(t=encodeURI(t)),t}).join("")}function U(t){var n=[],e=j(t.url,t.params,n);return n.forEach(function(n){delete t.params[n]}),e}function R(t,n){var e,o=this||{},r=t;return s(t)&&(r={url:t,params:n}),r=v({},R.options,o.$options,r),R.transforms.forEach(function(t){e=A(t,e,o.$vm)}),e(r)}function A(t,n,e){return function(o){return t.call(e,o,n)}}function S(t,n,e){var o,r=ut(n),i=h(n);m(n,function(n,u){o=f(n)||ut(n),e&&(u=e+"["+(i||o?u:"")+"]"),!e&&r?t.add(n.name,n.value):o?S(t,n,u):t.add(u,n)})}function k(t){return new n(function(n){var e=new XDomainRequest,o=function(o){var r=t.respondWith(e.responseText,{status:e.status,statusText:e.statusText});n(r)};t.abort=function(){return e.abort()},e.open(t.method,t.getUrl(),!0),e.timeout=0,e.onload=o,e.onerror=o,e.ontimeout=function(){},e.onprogress=function(){},e.send(t.getBody())})}function H(t,n){!c(t.crossOrigin)&&I(t)&&(t.crossOrigin=!0),t.crossOrigin&&(ht||(t.client=k),delete t.emulateHTTP),n()}function I(t){var n=R.parse(R(t));return n.protocol!==ft.protocol||n.host!==ft.host}function L(t,n){t.emulateJSON&&h(t.body)&&(t.body=R.params(t.body),t.headers["Content-Type"]="application/x-www-form-urlencoded"),p(t.body)&&delete t.headers["Content-Type"],h(t.body)&&(t.body=JSON.stringify(t.body)),n(function(t){var n=t.headers["Content-Type"];if(s(n)&&0===n.indexOf("application/json"))try{t.data=t.json()}catch(e){t.data=null}else t.data=t.text()})}function q(t){return new n(function(n){var e,o,r=t.jsonp||"callback",i="_jsonp"+Math.random().toString(36).substr(2),u=null;e=function(e){var r=0;"load"===e.type&&null!==u?r=200:"error"===e.type&&(r=404),n(t.respondWith(u,{status:r})),delete window[i],document.body.removeChild(o)},t.params[r]=i,window[i]=function(t){u=JSON.stringify(t)},o=document.createElement("script"),o.src=t.getUrl(),o.type="text/javascript",o.async=!0,o.onload=e,o.onerror=e,document.body.appendChild(o)})}function N(t,n){"JSONP"==t.method&&(t.client=q),n(function(n){"JSONP"==t.method&&(n.data=n.json())})}function D(t,n){a(t.before)&&t.before.call(this,t),n()}function J(t,n){t.emulateHTTP&&/^(PUT|PATCH|DELETE)$/i.test(t.method)&&(t.headers["X-HTTP-Method-Override"]=t.method,t.method="POST"),n()}function M(t,n){t.method=t.method.toUpperCase(),t.headers=st({},V.headers.common,t.crossOrigin?{}:V.headers.custom,V.headers[t.method.toLowerCase()],t.headers),n()}function X(t,n){var e;t.timeout&&(e=setTimeout(function(){t.abort()},t.timeout)),n(function(t){clearTimeout(e)})}function W(t){return new n(function(n){var e=new XMLHttpRequest,o=function(o){var r=t.respondWith("response"in e?e.response:e.responseText,{status:1223===e.status?204:e.status,statusText:1223===e.status?"No Content":u(e.statusText),headers:B(e.getAllResponseHeaders())});n(r)};t.abort=function(){return e.abort()},e.open(t.method,t.getUrl(),!0),e.timeout=0,e.onload=o,e.onerror=o,t.progress&&("GET"===t.method?e.addEventListener("progress",t.progress):/^(POST|PUT)$/i.test(t.method)&&e.upload.addEventListener("progress",t.progress)),t.credentials===!0&&(e.withCredentials=!0),m(t.headers||{},function(t,n){e.setRequestHeader(n,t)}),e.send(t.getBody())})}function B(t){var n,e,o,r={};return m(u(t).split("\n"),function(t){o=t.indexOf(":"),e=u(t.slice(0,o)),n=u(t.slice(o+1)),r[e]?ut(r[e])?r[e].push(n):r[e]=[r[e],n]:r[e]=n}),r}function F(t){function e(e){return new n(function(n){function s(){r=i.pop(),a(r)?r.call(t,e,c):(o("Invalid interceptor of type "+typeof r+", must be a function"),c())}function c(e){if(a(e))u.unshift(e);else if(f(e))return u.forEach(function(n){e=l(e,function(e){return n.call(t,e)||e})}),void l(e,n);s()}s()},t)}var r,i=[G],u=[];return f(t)||(t=null),e.use=function(t){i.push(t)},e}function G(t,n){var e=t.client||W;n(e(t))}function V(t){var e=this||{},o=F(e.$vm);return y(t||{},e.$options,V.options),V.interceptors.forEach(function(t){o.use(t)}),o(new dt(t)).then(function(t){return t.ok?t:n.reject(t)},function(t){return t instanceof Error&&r(t),n.reject(t)})}function _(t,n,e,o){var r=this||{},i={};return e=st({},_.actions,e),m(e,function(e,u){e=v({url:t,params:n||{}},o,e),i[u]=function(){return(r.$http||V)(z(e,arguments))}}),i}function z(t,n){var e,o=st({},t),r={};switch(n.length){case 2:r=n[0],e=n[1];break;case 1:/^(POST|PUT|PATCH)$/i.test(o.method)?e=n[0]:r=n[0];break;case 0:break;default:throw"Expected up to 4 arguments [params, body], got "+n.length+" arguments"}return o.body=e,o.params=st({},o.params,r),o}function K(t){K.installed||(e(t),t.url=R,t.http=V,t.resource=_,t.Promise=n,Object.defineProperties(t.prototype,{$url:{get:function(){return d(t.url,this,this.$options.url)}},$http:{get:function(){return d(t.http,this,this.$options.http)}},$resource:{get:function(){return t.resource.bind(this)}},$promise:{get:function(){var n=this;return function(e){return new t.Promise(e,n)}}}}))}var Q=0,Y=1,Z=2;t.reject=function(n){return new t(function(t,e){e(n)})},t.resolve=function(n){return new t(function(t,e){t(n)})},t.all=function(n){return new t(function(e,o){function r(t){return function(o){u[t]=o,i+=1,i===n.length&&e(u)}}var i=0,u=[];0===n.length&&e(u);for(var s=0;s<n.length;s+=1)t.resolve(n[s]).then(r(s),o)})},t.race=function(n){return new t(function(e,o){for(var r=0;r<n.length;r+=1)t.resolve(n[r]).then(e,o)})};var tt=t.prototype;tt.resolve=function(t){var n=this;if(n.state===Z){if(t===n)throw new TypeError("Promise settled with itself.");var e=!1;try{var o=t&&t.then;if(null!==t&&"object"==typeof t&&"function"==typeof o)return void o.call(t,function(t){e||n.resolve(t),e=!0},function(t){e||n.reject(t),e=!0})}catch(r){return void(e||n.reject(r))}n.state=Q,n.value=t,n.notify()}},tt.reject=function(t){var n=this;if(n.state===Z){if(t===n)throw new TypeError("Promise settled with itself.");n.state=Y,n.value=t,n.notify()}},tt.notify=function(){var t=this;i(function(){if(t.state!==Z)for(;t.deferred.length;){var n=t.deferred.shift(),e=n[0],o=n[1],r=n[2],i=n[3];try{t.state===Q?r("function"==typeof e?e.call(void 0,t.value):t.value):t.state===Y&&("function"==typeof o?r(o.call(void 0,t.value)):i(t.value))}catch(u){i(u)}}})},tt.then=function(n,e){var o=this;return new t(function(t,r){o.deferred.push([n,e,t,r]),o.notify()})},tt["catch"]=function(t){return this.then(void 0,t)};var nt=window.Promise||t;n.all=function(t,e){return new n(nt.all(t),e)},n.resolve=function(t,e){return new n(nt.resolve(t),e)},n.reject=function(t,e){return new n(nt.reject(t),e)},n.race=function(t,e){return new n(nt.race(t),e)};var et=n.prototype;et.bind=function(t){return this.context=t,this},et.then=function(t,e){return t&&t.bind&&this.context&&(t=t.bind(this.context)),e&&e.bind&&this.context&&(e=e.bind(this.context)),new n(this.promise.then(t,e),this.context)},et["catch"]=function(t){return t&&t.bind&&this.context&&(t=t.bind(this.context)),new n(this.promise["catch"](t),this.context)},et["finally"]=function(t){return this.then(function(n){return t.call(this),n},function(n){return t.call(this),nt.reject(n)})};var ot=!1,rt={},it=[],ut=Array.isArray,st=Object.assign||b,ct=document.documentMode,at=document.createElement("a");R.options={url:"",root:null,params:{}},R.transforms=[U,T,w],R.params=function(t){var n=[],e=encodeURIComponent;return n.add=function(t,n){a(n)&&(n=n()),null===n&&(n=""),this.push(e(t)+"="+e(n))},S(n,t),n.join("&").replace(/%20/g,"+")},R.parse=function(t){return ct&&(at.href=t,t=at.href),at.href=t,{href:at.href,protocol:at.protocol?at.protocol.replace(/:$/,""):"",port:at.port,host:at.host,hostname:at.hostname,pathname:"/"===at.pathname.charAt(0)?at.pathname:"/"+at.pathname,search:at.search?at.search.replace(/^\?/,""):"",hash:at.hash?at.hash.replace(/^#/,""):""}};var ft=R.parse(location.href),ht="withCredentials"in new XMLHttpRequest,pt=function(t,n){if(!(t instanceof n))throw new TypeError("Cannot call a class as a function")},lt=function(){function t(n,e){var o=e.url,r=e.headers,i=e.status,u=e.statusText;pt(this,t),this.url=o,this.body=n,this.headers=r||{},this.status=i||0,this.statusText=u||"",this.ok=i>=200&&i<300}return t.prototype.text=function(){return this.body},t.prototype.blob=function(){return new Blob([this.body])},t.prototype.json=function(){return JSON.parse(this.body)},t}(),dt=function(){function t(n){pt(this,t),this.method="GET",this.body=null,this.params={},this.headers={},st(this,n)}return t.prototype.getUrl=function(){return R(this)},t.prototype.getBody=function(){return this.body},t.prototype.respondWith=function(t,n){return new lt(t,st(n||{},{url:this.getUrl()}))},t}(),mt={"X-Requested-With":"XMLHttpRequest"},vt={Accept:"application/json, text/plain, */*"},yt={"Content-Type":"application/json;charset=utf-8"};return V.options={},V.headers={put:yt,post:yt,patch:yt,"delete":yt,custom:mt,common:vt},V.interceptors=[D,X,J,L,N,M,H],["get","delete","head","jsonp"].forEach(function(t){V[t]=function(n,e){return this(st(e||{},{url:n,method:t}))}}),["post","put","patch"].forEach(function(t){V[t]=function(n,e,o){return this(st(o||{},{url:n,method:t,body:e}))}}),_.actions={get:{method:"GET"},save:{method:"POST"},query:{method:"GET"},update:{method:"PUT"},remove:{method:"DELETE"},"delete":{method:"DELETE"}},"undefined"!=typeof window&&window.Vue&&window.Vue.use(K),K});
\ No newline at end of file diff --git a/vendor/assets/javascripts/vue.full.js b/vendor/assets/javascripts/vue.full.js new file mode 100644 index 00000000000..7ae95897a01 --- /dev/null +++ b/vendor/assets/javascripts/vue.full.js @@ -0,0 +1,10073 @@ +/*! + * Vue.js v1.0.26 + * (c) 2016 Evan You + * Released under the MIT License. + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Vue = factory()); +}(this, function () { 'use strict'; + + function set(obj, key, val) { + if (hasOwn(obj, key)) { + obj[key] = val; + return; + } + if (obj._isVue) { + set(obj._data, key, val); + return; + } + var ob = obj.__ob__; + if (!ob) { + obj[key] = val; + return; + } + ob.convert(key, val); + ob.dep.notify(); + if (ob.vms) { + var i = ob.vms.length; + while (i--) { + var vm = ob.vms[i]; + vm._proxy(key); + vm._digest(); + } + } + return val; + } + + /** + * Delete a property and trigger change if necessary. + * + * @param {Object} obj + * @param {String} key + */ + + function del(obj, key) { + if (!hasOwn(obj, key)) { + return; + } + delete obj[key]; + var ob = obj.__ob__; + if (!ob) { + if (obj._isVue) { + delete obj._data[key]; + obj._digest(); + } + return; + } + ob.dep.notify(); + if (ob.vms) { + var i = ob.vms.length; + while (i--) { + var vm = ob.vms[i]; + vm._unproxy(key); + vm._digest(); + } + } + } + + var hasOwnProperty = Object.prototype.hasOwnProperty; + /** + * Check whether the object has the property. + * + * @param {Object} obj + * @param {String} key + * @return {Boolean} + */ + + function hasOwn(obj, key) { + return hasOwnProperty.call(obj, key); + } + + /** + * Check if an expression is a literal value. + * + * @param {String} exp + * @return {Boolean} + */ + + var literalValueRE = /^\s?(true|false|-?[\d\.]+|'[^']*'|"[^"]*")\s?$/; + + function isLiteral(exp) { + return literalValueRE.test(exp); + } + + /** + * Check if a string starts with $ or _ + * + * @param {String} str + * @return {Boolean} + */ + + function isReserved(str) { + var c = (str + '').charCodeAt(0); + return c === 0x24 || c === 0x5F; + } + + /** + * Guard text output, make sure undefined outputs + * empty string + * + * @param {*} value + * @return {String} + */ + + function _toString(value) { + return value == null ? '' : value.toString(); + } + + /** + * Check and convert possible numeric strings to numbers + * before setting back to data + * + * @param {*} value + * @return {*|Number} + */ + + function toNumber(value) { + if (typeof value !== 'string') { + return value; + } else { + var parsed = Number(value); + return isNaN(parsed) ? value : parsed; + } + } + + /** + * Convert string boolean literals into real booleans. + * + * @param {*} value + * @return {*|Boolean} + */ + + function toBoolean(value) { + return value === 'true' ? true : value === 'false' ? false : value; + } + + /** + * Strip quotes from a string + * + * @param {String} str + * @return {String | false} + */ + + function stripQuotes(str) { + var a = str.charCodeAt(0); + var b = str.charCodeAt(str.length - 1); + return a === b && (a === 0x22 || a === 0x27) ? str.slice(1, -1) : str; + } + + /** + * Camelize a hyphen-delmited string. + * + * @param {String} str + * @return {String} + */ + + var camelizeRE = /-(\w)/g; + + function camelize(str) { + return str.replace(camelizeRE, toUpper); + } + + function toUpper(_, c) { + return c ? c.toUpperCase() : ''; + } + + /** + * Hyphenate a camelCase string. + * + * @param {String} str + * @return {String} + */ + + var hyphenateRE = /([a-z\d])([A-Z])/g; + + function hyphenate(str) { + return str.replace(hyphenateRE, '$1-$2').toLowerCase(); + } + + /** + * Converts hyphen/underscore/slash delimitered names into + * camelized classNames. + * + * e.g. my-component => MyComponent + * some_else => SomeElse + * some/comp => SomeComp + * + * @param {String} str + * @return {String} + */ + + var classifyRE = /(?:^|[-_\/])(\w)/g; + + function classify(str) { + return str.replace(classifyRE, toUpper); + } + + /** + * Simple bind, faster than native + * + * @param {Function} fn + * @param {Object} ctx + * @return {Function} + */ + + function bind(fn, ctx) { + return function (a) { + var l = arguments.length; + return l ? l > 1 ? fn.apply(ctx, arguments) : fn.call(ctx, a) : fn.call(ctx); + }; + } + + /** + * Convert an Array-like object to a real Array. + * + * @param {Array-like} list + * @param {Number} [start] - start index + * @return {Array} + */ + + function toArray(list, start) { + start = start || 0; + var i = list.length - start; + var ret = new Array(i); + while (i--) { + ret[i] = list[i + start]; + } + return ret; + } + + /** + * Mix properties into target object. + * + * @param {Object} to + * @param {Object} from + */ + + function extend(to, from) { + var keys = Object.keys(from); + var i = keys.length; + while (i--) { + to[keys[i]] = from[keys[i]]; + } + return to; + } + + /** + * Quick object check - this is primarily used to tell + * Objects from primitive values when we know the value + * is a JSON-compliant type. + * + * @param {*} obj + * @return {Boolean} + */ + + function isObject(obj) { + return obj !== null && typeof obj === 'object'; + } + + /** + * Strict object type check. Only returns true + * for plain JavaScript objects. + * + * @param {*} obj + * @return {Boolean} + */ + + var toString = Object.prototype.toString; + var OBJECT_STRING = '[object Object]'; + + function isPlainObject(obj) { + return toString.call(obj) === OBJECT_STRING; + } + + /** + * Array type check. + * + * @param {*} obj + * @return {Boolean} + */ + + var isArray = Array.isArray; + + /** + * Define a property. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + * @param {Boolean} [enumerable] + */ + + function def(obj, key, val, enumerable) { + Object.defineProperty(obj, key, { + value: val, + enumerable: !!enumerable, + writable: true, + configurable: true + }); + } + + /** + * Debounce a function so it only gets called after the + * input stops arriving after the given wait period. + * + * @param {Function} func + * @param {Number} wait + * @return {Function} - the debounced function + */ + + function _debounce(func, wait) { + var timeout, args, context, timestamp, result; + var later = function later() { + var last = Date.now() - timestamp; + if (last < wait && last >= 0) { + timeout = setTimeout(later, wait - last); + } else { + timeout = null; + result = func.apply(context, args); + if (!timeout) context = args = null; + } + }; + return function () { + context = this; + args = arguments; + timestamp = Date.now(); + if (!timeout) { + timeout = setTimeout(later, wait); + } + return result; + }; + } + + /** + * Manual indexOf because it's slightly faster than + * native. + * + * @param {Array} arr + * @param {*} obj + */ + + function indexOf(arr, obj) { + var i = arr.length; + while (i--) { + if (arr[i] === obj) return i; + } + return -1; + } + + /** + * Make a cancellable version of an async callback. + * + * @param {Function} fn + * @return {Function} + */ + + function cancellable(fn) { + var cb = function cb() { + if (!cb.cancelled) { + return fn.apply(this, arguments); + } + }; + cb.cancel = function () { + cb.cancelled = true; + }; + return cb; + } + + /** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + * + * @param {*} a + * @param {*} b + * @return {Boolean} + */ + + function looseEqual(a, b) { + /* eslint-disable eqeqeq */ + return a == b || (isObject(a) && isObject(b) ? JSON.stringify(a) === JSON.stringify(b) : false); + /* eslint-enable eqeqeq */ + } + + var hasProto = ('__proto__' in {}); + + // Browser environment sniffing + var inBrowser = typeof window !== 'undefined' && Object.prototype.toString.call(window) !== '[object Object]'; + + // detect devtools + var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; + + // UA sniffing for working around browser-specific quirks + var UA = inBrowser && window.navigator.userAgent.toLowerCase(); + var isIE = UA && UA.indexOf('trident') > 0; + var isIE9 = UA && UA.indexOf('msie 9.0') > 0; + var isAndroid = UA && UA.indexOf('android') > 0; + var isIos = UA && /(iphone|ipad|ipod|ios)/i.test(UA); + var iosVersionMatch = isIos && UA.match(/os ([\d_]+)/); + var iosVersion = iosVersionMatch && iosVersionMatch[1].split('_'); + + // detecting iOS UIWebView by indexedDB + var hasMutationObserverBug = iosVersion && Number(iosVersion[0]) >= 9 && Number(iosVersion[1]) >= 3 && !window.indexedDB; + + var transitionProp = undefined; + var transitionEndEvent = undefined; + var animationProp = undefined; + var animationEndEvent = undefined; + + // Transition property/event sniffing + if (inBrowser && !isIE9) { + var isWebkitTrans = window.ontransitionend === undefined && window.onwebkittransitionend !== undefined; + var isWebkitAnim = window.onanimationend === undefined && window.onwebkitanimationend !== undefined; + transitionProp = isWebkitTrans ? 'WebkitTransition' : 'transition'; + transitionEndEvent = isWebkitTrans ? 'webkitTransitionEnd' : 'transitionend'; + animationProp = isWebkitAnim ? 'WebkitAnimation' : 'animation'; + animationEndEvent = isWebkitAnim ? 'webkitAnimationEnd' : 'animationend'; + } + + /** + * Defer a task to execute it asynchronously. Ideally this + * should be executed as a microtask, so we leverage + * MutationObserver if it's available, and fallback to + * setTimeout(0). + * + * @param {Function} cb + * @param {Object} ctx + */ + + var nextTick = (function () { + var callbacks = []; + var pending = false; + var timerFunc; + function nextTickHandler() { + pending = false; + var copies = callbacks.slice(0); + callbacks = []; + for (var i = 0; i < copies.length; i++) { + copies[i](); + } + } + + /* istanbul ignore if */ + if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) { + var counter = 1; + var observer = new MutationObserver(nextTickHandler); + var textNode = document.createTextNode(counter); + observer.observe(textNode, { + characterData: true + }); + timerFunc = function () { + counter = (counter + 1) % 2; + textNode.data = counter; + }; + } else { + // webpack attempts to inject a shim for setImmediate + // if it is used as a global, so we have to work around that to + // avoid bundling unnecessary code. + var context = inBrowser ? window : typeof global !== 'undefined' ? global : {}; + timerFunc = context.setImmediate || setTimeout; + } + return function (cb, ctx) { + var func = ctx ? function () { + cb.call(ctx); + } : cb; + callbacks.push(func); + if (pending) return; + pending = true; + timerFunc(nextTickHandler, 0); + }; + })(); + + var _Set = undefined; + /* istanbul ignore if */ + if (typeof Set !== 'undefined' && Set.toString().match(/native code/)) { + // use native Set when available. + _Set = Set; + } else { + // a non-standard Set polyfill that only works with primitive keys. + _Set = function () { + this.set = Object.create(null); + }; + _Set.prototype.has = function (key) { + return this.set[key] !== undefined; + }; + _Set.prototype.add = function (key) { + this.set[key] = 1; + }; + _Set.prototype.clear = function () { + this.set = Object.create(null); + }; + } + + function Cache(limit) { + this.size = 0; + this.limit = limit; + this.head = this.tail = undefined; + this._keymap = Object.create(null); + } + + var p = Cache.prototype; + + /** + * Put <value> into the cache associated with <key>. + * Returns the entry which was removed to make room for + * the new entry. Otherwise undefined is returned. + * (i.e. if there was enough room already). + * + * @param {String} key + * @param {*} value + * @return {Entry|undefined} + */ + + p.put = function (key, value) { + var removed; + + var entry = this.get(key, true); + if (!entry) { + if (this.size === this.limit) { + removed = this.shift(); + } + entry = { + key: key + }; + this._keymap[key] = entry; + if (this.tail) { + this.tail.newer = entry; + entry.older = this.tail; + } else { + this.head = entry; + } + this.tail = entry; + this.size++; + } + entry.value = value; + + return removed; + }; + + /** + * Purge the least recently used (oldest) entry from the + * cache. Returns the removed entry or undefined if the + * cache was empty. + */ + + p.shift = function () { + var entry = this.head; + if (entry) { + this.head = this.head.newer; + this.head.older = undefined; + entry.newer = entry.older = undefined; + this._keymap[entry.key] = undefined; + this.size--; + } + return entry; + }; + + /** + * Get and register recent use of <key>. Returns the value + * associated with <key> or undefined if not in cache. + * + * @param {String} key + * @param {Boolean} returnEntry + * @return {Entry|*} + */ + + p.get = function (key, returnEntry) { + var entry = this._keymap[key]; + if (entry === undefined) return; + if (entry === this.tail) { + return returnEntry ? entry : entry.value; + } + // HEAD--------------TAIL + // <.older .newer> + // <--- add direction -- + // A B C <D> E + if (entry.newer) { + if (entry === this.head) { + this.head = entry.newer; + } + entry.newer.older = entry.older; // C <-- E. + } + if (entry.older) { + entry.older.newer = entry.newer; // C. --> E + } + entry.newer = undefined; // D --x + entry.older = this.tail; // D. --> E + if (this.tail) { + this.tail.newer = entry; // E. <-- D + } + this.tail = entry; + return returnEntry ? entry : entry.value; + }; + + var cache$1 = new Cache(1000); + var filterTokenRE = /[^\s'"]+|'[^']*'|"[^"]*"/g; + var reservedArgRE = /^in$|^-?\d+/; + + /** + * Parser state + */ + + var str; + var dir; + var c; + var prev; + var i; + var l; + var lastFilterIndex; + var inSingle; + var inDouble; + var curly; + var square; + var paren; + /** + * Push a filter to the current directive object + */ + + function pushFilter() { + var exp = str.slice(lastFilterIndex, i).trim(); + var filter; + if (exp) { + filter = {}; + var tokens = exp.match(filterTokenRE); + filter.name = tokens[0]; + if (tokens.length > 1) { + filter.args = tokens.slice(1).map(processFilterArg); + } + } + if (filter) { + (dir.filters = dir.filters || []).push(filter); + } + lastFilterIndex = i + 1; + } + + /** + * Check if an argument is dynamic and strip quotes. + * + * @param {String} arg + * @return {Object} + */ + + function processFilterArg(arg) { + if (reservedArgRE.test(arg)) { + return { + value: toNumber(arg), + dynamic: false + }; + } else { + var stripped = stripQuotes(arg); + var dynamic = stripped === arg; + return { + value: dynamic ? arg : stripped, + dynamic: dynamic + }; + } + } + + /** + * Parse a directive value and extract the expression + * and its filters into a descriptor. + * + * Example: + * + * "a + 1 | uppercase" will yield: + * { + * expression: 'a + 1', + * filters: [ + * { name: 'uppercase', args: null } + * ] + * } + * + * @param {String} s + * @return {Object} + */ + + function parseDirective(s) { + var hit = cache$1.get(s); + if (hit) { + return hit; + } + + // reset parser state + str = s; + inSingle = inDouble = false; + curly = square = paren = 0; + lastFilterIndex = 0; + dir = {}; + + for (i = 0, l = str.length; i < l; i++) { + prev = c; + c = str.charCodeAt(i); + if (inSingle) { + // check single quote + if (c === 0x27 && prev !== 0x5C) inSingle = !inSingle; + } else if (inDouble) { + // check double quote + if (c === 0x22 && prev !== 0x5C) inDouble = !inDouble; + } else if (c === 0x7C && // pipe + str.charCodeAt(i + 1) !== 0x7C && str.charCodeAt(i - 1) !== 0x7C) { + if (dir.expression == null) { + // first filter, end of expression + lastFilterIndex = i + 1; + dir.expression = str.slice(0, i).trim(); + } else { + // already has filter + pushFilter(); + } + } else { + switch (c) { + case 0x22: + inDouble = true;break; // " + case 0x27: + inSingle = true;break; // ' + case 0x28: + paren++;break; // ( + case 0x29: + paren--;break; // ) + case 0x5B: + square++;break; // [ + case 0x5D: + square--;break; // ] + case 0x7B: + curly++;break; // { + case 0x7D: + curly--;break; // } + } + } + } + + if (dir.expression == null) { + dir.expression = str.slice(0, i).trim(); + } else if (lastFilterIndex !== 0) { + pushFilter(); + } + + cache$1.put(s, dir); + return dir; + } + +var directive = Object.freeze({ + parseDirective: parseDirective + }); + + var regexEscapeRE = /[-.*+?^${}()|[\]\/\\]/g; + var cache = undefined; + var tagRE = undefined; + var htmlRE = undefined; + /** + * Escape a string so it can be used in a RegExp + * constructor. + * + * @param {String} str + */ + + function escapeRegex(str) { + return str.replace(regexEscapeRE, '\\$&'); + } + + function compileRegex() { + var open = escapeRegex(config.delimiters[0]); + var close = escapeRegex(config.delimiters[1]); + var unsafeOpen = escapeRegex(config.unsafeDelimiters[0]); + var unsafeClose = escapeRegex(config.unsafeDelimiters[1]); + tagRE = new RegExp(unsafeOpen + '((?:.|\\n)+?)' + unsafeClose + '|' + open + '((?:.|\\n)+?)' + close, 'g'); + htmlRE = new RegExp('^' + unsafeOpen + '((?:.|\\n)+?)' + unsafeClose + '$'); + // reset cache + cache = new Cache(1000); + } + + /** + * Parse a template text string into an array of tokens. + * + * @param {String} text + * @return {Array<Object> | null} + * - {String} type + * - {String} value + * - {Boolean} [html] + * - {Boolean} [oneTime] + */ + + function parseText(text) { + if (!cache) { + compileRegex(); + } + var hit = cache.get(text); + if (hit) { + return hit; + } + if (!tagRE.test(text)) { + return null; + } + var tokens = []; + var lastIndex = tagRE.lastIndex = 0; + var match, index, html, value, first, oneTime; + /* eslint-disable no-cond-assign */ + while (match = tagRE.exec(text)) { + /* eslint-enable no-cond-assign */ + index = match.index; + // push text token + if (index > lastIndex) { + tokens.push({ + value: text.slice(lastIndex, index) + }); + } + // tag token + html = htmlRE.test(match[0]); + value = html ? match[1] : match[2]; + first = value.charCodeAt(0); + oneTime = first === 42; // * + value = oneTime ? value.slice(1) : value; + tokens.push({ + tag: true, + value: value.trim(), + html: html, + oneTime: oneTime + }); + lastIndex = index + match[0].length; + } + if (lastIndex < text.length) { + tokens.push({ + value: text.slice(lastIndex) + }); + } + cache.put(text, tokens); + return tokens; + } + + /** + * Format a list of tokens into an expression. + * e.g. tokens parsed from 'a {{b}} c' can be serialized + * into one single expression as '"a " + b + " c"'. + * + * @param {Array} tokens + * @param {Vue} [vm] + * @return {String} + */ + + function tokensToExp(tokens, vm) { + if (tokens.length > 1) { + return tokens.map(function (token) { + return formatToken(token, vm); + }).join('+'); + } else { + return formatToken(tokens[0], vm, true); + } + } + + /** + * Format a single token. + * + * @param {Object} token + * @param {Vue} [vm] + * @param {Boolean} [single] + * @return {String} + */ + + function formatToken(token, vm, single) { + return token.tag ? token.oneTime && vm ? '"' + vm.$eval(token.value) + '"' : inlineFilters(token.value, single) : '"' + token.value + '"'; + } + + /** + * For an attribute with multiple interpolation tags, + * e.g. attr="some-{{thing | filter}}", in order to combine + * the whole thing into a single watchable expression, we + * have to inline those filters. This function does exactly + * that. This is a bit hacky but it avoids heavy changes + * to directive parser and watcher mechanism. + * + * @param {String} exp + * @param {Boolean} single + * @return {String} + */ + + var filterRE = /[^|]\|[^|]/; + function inlineFilters(exp, single) { + if (!filterRE.test(exp)) { + return single ? exp : '(' + exp + ')'; + } else { + var dir = parseDirective(exp); + if (!dir.filters) { + return '(' + exp + ')'; + } else { + return 'this._applyFilters(' + dir.expression + // value + ',null,' + // oldValue (null for read) + JSON.stringify(dir.filters) + // filter descriptors + ',false)'; // write? + } + } + } + +var text = Object.freeze({ + compileRegex: compileRegex, + parseText: parseText, + tokensToExp: tokensToExp + }); + + var delimiters = ['{{', '}}']; + var unsafeDelimiters = ['{{{', '}}}']; + + var config = Object.defineProperties({ + + /** + * Whether to print debug messages. + * Also enables stack trace for warnings. + * + * @type {Boolean} + */ + + debug: false, + + /** + * Whether to suppress warnings. + * + * @type {Boolean} + */ + + silent: false, + + /** + * Whether to use async rendering. + */ + + async: true, + + /** + * Whether to warn against errors caught when evaluating + * expressions. + */ + + warnExpressionErrors: true, + + /** + * Whether to allow devtools inspection. + * Disabled by default in production builds. + */ + + devtools: 'development' !== 'production', + + /** + * Internal flag to indicate the delimiters have been + * changed. + * + * @type {Boolean} + */ + + _delimitersChanged: true, + + /** + * List of asset types that a component can own. + * + * @type {Array} + */ + + _assetTypes: ['component', 'directive', 'elementDirective', 'filter', 'transition', 'partial'], + + /** + * prop binding modes + */ + + _propBindingModes: { + ONE_WAY: 0, + TWO_WAY: 1, + ONE_TIME: 2 + }, + + /** + * Max circular updates allowed in a batcher flush cycle. + */ + + _maxUpdateCount: 100 + + }, { + delimiters: { /** + * Interpolation delimiters. Changing these would trigger + * the text parser to re-compile the regular expressions. + * + * @type {Array<String>} + */ + + get: function get() { + return delimiters; + }, + set: function set(val) { + delimiters = val; + compileRegex(); + }, + configurable: true, + enumerable: true + }, + unsafeDelimiters: { + get: function get() { + return unsafeDelimiters; + }, + set: function set(val) { + unsafeDelimiters = val; + compileRegex(); + }, + configurable: true, + enumerable: true + } + }); + + var warn = undefined; + var formatComponentName = undefined; + + if ('development' !== 'production') { + (function () { + var hasConsole = typeof console !== 'undefined'; + + warn = function (msg, vm) { + if (hasConsole && !config.silent) { + console.error('[Vue warn]: ' + msg + (vm ? formatComponentName(vm) : '')); + } + }; + + formatComponentName = function (vm) { + var name = vm._isVue ? vm.$options.name : vm.name; + return name ? ' (found in component: <' + hyphenate(name) + '>)' : ''; + }; + })(); + } + + /** + * Append with transition. + * + * @param {Element} el + * @param {Element} target + * @param {Vue} vm + * @param {Function} [cb] + */ + + function appendWithTransition(el, target, vm, cb) { + applyTransition(el, 1, function () { + target.appendChild(el); + }, vm, cb); + } + + /** + * InsertBefore with transition. + * + * @param {Element} el + * @param {Element} target + * @param {Vue} vm + * @param {Function} [cb] + */ + + function beforeWithTransition(el, target, vm, cb) { + applyTransition(el, 1, function () { + before(el, target); + }, vm, cb); + } + + /** + * Remove with transition. + * + * @param {Element} el + * @param {Vue} vm + * @param {Function} [cb] + */ + + function removeWithTransition(el, vm, cb) { + applyTransition(el, -1, function () { + remove(el); + }, vm, cb); + } + + /** + * Apply transitions with an operation callback. + * + * @param {Element} el + * @param {Number} direction + * 1: enter + * -1: leave + * @param {Function} op - the actual DOM operation + * @param {Vue} vm + * @param {Function} [cb] + */ + + function applyTransition(el, direction, op, vm, cb) { + var transition = el.__v_trans; + if (!transition || + // skip if there are no js hooks and CSS transition is + // not supported + !transition.hooks && !transitionEndEvent || + // skip transitions for initial compile + !vm._isCompiled || + // if the vm is being manipulated by a parent directive + // during the parent's compilation phase, skip the + // animation. + vm.$parent && !vm.$parent._isCompiled) { + op(); + if (cb) cb(); + return; + } + var action = direction > 0 ? 'enter' : 'leave'; + transition[action](op, cb); + } + +var transition = Object.freeze({ + appendWithTransition: appendWithTransition, + beforeWithTransition: beforeWithTransition, + removeWithTransition: removeWithTransition, + applyTransition: applyTransition + }); + + /** + * Query an element selector if it's not an element already. + * + * @param {String|Element} el + * @return {Element} + */ + + function query(el) { + if (typeof el === 'string') { + var selector = el; + el = document.querySelector(el); + if (!el) { + 'development' !== 'production' && warn('Cannot find element: ' + selector); + } + } + return el; + } + + /** + * Check if a node is in the document. + * Note: document.documentElement.contains should work here + * but always returns false for comment nodes in phantomjs, + * making unit tests difficult. This is fixed by doing the + * contains() check on the node's parentNode instead of + * the node itself. + * + * @param {Node} node + * @return {Boolean} + */ + + function inDoc(node) { + if (!node) return false; + var doc = node.ownerDocument.documentElement; + var parent = node.parentNode; + return doc === node || doc === parent || !!(parent && parent.nodeType === 1 && doc.contains(parent)); + } + + /** + * Get and remove an attribute from a node. + * + * @param {Node} node + * @param {String} _attr + */ + + function getAttr(node, _attr) { + var val = node.getAttribute(_attr); + if (val !== null) { + node.removeAttribute(_attr); + } + return val; + } + + /** + * Get an attribute with colon or v-bind: prefix. + * + * @param {Node} node + * @param {String} name + * @return {String|null} + */ + + function getBindAttr(node, name) { + var val = getAttr(node, ':' + name); + if (val === null) { + val = getAttr(node, 'v-bind:' + name); + } + return val; + } + + /** + * Check the presence of a bind attribute. + * + * @param {Node} node + * @param {String} name + * @return {Boolean} + */ + + function hasBindAttr(node, name) { + return node.hasAttribute(name) || node.hasAttribute(':' + name) || node.hasAttribute('v-bind:' + name); + } + + /** + * Insert el before target + * + * @param {Element} el + * @param {Element} target + */ + + function before(el, target) { + target.parentNode.insertBefore(el, target); + } + + /** + * Insert el after target + * + * @param {Element} el + * @param {Element} target + */ + + function after(el, target) { + if (target.nextSibling) { + before(el, target.nextSibling); + } else { + target.parentNode.appendChild(el); + } + } + + /** + * Remove el from DOM + * + * @param {Element} el + */ + + function remove(el) { + el.parentNode.removeChild(el); + } + + /** + * Prepend el to target + * + * @param {Element} el + * @param {Element} target + */ + + function prepend(el, target) { + if (target.firstChild) { + before(el, target.firstChild); + } else { + target.appendChild(el); + } + } + + /** + * Replace target with el + * + * @param {Element} target + * @param {Element} el + */ + + function replace(target, el) { + var parent = target.parentNode; + if (parent) { + parent.replaceChild(el, target); + } + } + + /** + * Add event listener shorthand. + * + * @param {Element} el + * @param {String} event + * @param {Function} cb + * @param {Boolean} [useCapture] + */ + + function on(el, event, cb, useCapture) { + el.addEventListener(event, cb, useCapture); + } + + /** + * Remove event listener shorthand. + * + * @param {Element} el + * @param {String} event + * @param {Function} cb + */ + + function off(el, event, cb) { + el.removeEventListener(event, cb); + } + + /** + * For IE9 compat: when both class and :class are present + * getAttribute('class') returns wrong value... + * + * @param {Element} el + * @return {String} + */ + + function getClass(el) { + var classname = el.className; + if (typeof classname === 'object') { + classname = classname.baseVal || ''; + } + return classname; + } + + /** + * In IE9, setAttribute('class') will result in empty class + * if the element also has the :class attribute; However in + * PhantomJS, setting `className` does not work on SVG elements... + * So we have to do a conditional check here. + * + * @param {Element} el + * @param {String} cls + */ + + function setClass(el, cls) { + /* istanbul ignore if */ + if (isIE9 && !/svg$/.test(el.namespaceURI)) { + el.className = cls; + } else { + el.setAttribute('class', cls); + } + } + + /** + * Add class with compatibility for IE & SVG + * + * @param {Element} el + * @param {String} cls + */ + + function addClass(el, cls) { + if (el.classList) { + el.classList.add(cls); + } else { + var cur = ' ' + getClass(el) + ' '; + if (cur.indexOf(' ' + cls + ' ') < 0) { + setClass(el, (cur + cls).trim()); + } + } + } + + /** + * Remove class with compatibility for IE & SVG + * + * @param {Element} el + * @param {String} cls + */ + + function removeClass(el, cls) { + if (el.classList) { + el.classList.remove(cls); + } else { + var cur = ' ' + getClass(el) + ' '; + var tar = ' ' + cls + ' '; + while (cur.indexOf(tar) >= 0) { + cur = cur.replace(tar, ' '); + } + setClass(el, cur.trim()); + } + if (!el.className) { + el.removeAttribute('class'); + } + } + + /** + * Extract raw content inside an element into a temporary + * container div + * + * @param {Element} el + * @param {Boolean} asFragment + * @return {Element|DocumentFragment} + */ + + function extractContent(el, asFragment) { + var child; + var rawContent; + /* istanbul ignore if */ + if (isTemplate(el) && isFragment(el.content)) { + el = el.content; + } + if (el.hasChildNodes()) { + trimNode(el); + rawContent = asFragment ? document.createDocumentFragment() : document.createElement('div'); + /* eslint-disable no-cond-assign */ + while (child = el.firstChild) { + /* eslint-enable no-cond-assign */ + rawContent.appendChild(child); + } + } + return rawContent; + } + + /** + * Trim possible empty head/tail text and comment + * nodes inside a parent. + * + * @param {Node} node + */ + + function trimNode(node) { + var child; + /* eslint-disable no-sequences */ + while ((child = node.firstChild, isTrimmable(child))) { + node.removeChild(child); + } + while ((child = node.lastChild, isTrimmable(child))) { + node.removeChild(child); + } + /* eslint-enable no-sequences */ + } + + function isTrimmable(node) { + return node && (node.nodeType === 3 && !node.data.trim() || node.nodeType === 8); + } + + /** + * Check if an element is a template tag. + * Note if the template appears inside an SVG its tagName + * will be in lowercase. + * + * @param {Element} el + */ + + function isTemplate(el) { + return el.tagName && el.tagName.toLowerCase() === 'template'; + } + + /** + * Create an "anchor" for performing dom insertion/removals. + * This is used in a number of scenarios: + * - fragment instance + * - v-html + * - v-if + * - v-for + * - component + * + * @param {String} content + * @param {Boolean} persist - IE trashes empty textNodes on + * cloneNode(true), so in certain + * cases the anchor needs to be + * non-empty to be persisted in + * templates. + * @return {Comment|Text} + */ + + function createAnchor(content, persist) { + var anchor = config.debug ? document.createComment(content) : document.createTextNode(persist ? ' ' : ''); + anchor.__v_anchor = true; + return anchor; + } + + /** + * Find a component ref attribute that starts with $. + * + * @param {Element} node + * @return {String|undefined} + */ + + var refRE = /^v-ref:/; + + function findRef(node) { + if (node.hasAttributes()) { + var attrs = node.attributes; + for (var i = 0, l = attrs.length; i < l; i++) { + var name = attrs[i].name; + if (refRE.test(name)) { + return camelize(name.replace(refRE, '')); + } + } + } + } + + /** + * Map a function to a range of nodes . + * + * @param {Node} node + * @param {Node} end + * @param {Function} op + */ + + function mapNodeRange(node, end, op) { + var next; + while (node !== end) { + next = node.nextSibling; + op(node); + node = next; + } + op(end); + } + + /** + * Remove a range of nodes with transition, store + * the nodes in a fragment with correct ordering, + * and call callback when done. + * + * @param {Node} start + * @param {Node} end + * @param {Vue} vm + * @param {DocumentFragment} frag + * @param {Function} cb + */ + + function removeNodeRange(start, end, vm, frag, cb) { + var done = false; + var removed = 0; + var nodes = []; + mapNodeRange(start, end, function (node) { + if (node === end) done = true; + nodes.push(node); + removeWithTransition(node, vm, onRemoved); + }); + function onRemoved() { + removed++; + if (done && removed >= nodes.length) { + for (var i = 0; i < nodes.length; i++) { + frag.appendChild(nodes[i]); + } + cb && cb(); + } + } + } + + /** + * Check if a node is a DocumentFragment. + * + * @param {Node} node + * @return {Boolean} + */ + + function isFragment(node) { + return node && node.nodeType === 11; + } + + /** + * Get outerHTML of elements, taking care + * of SVG elements in IE as well. + * + * @param {Element} el + * @return {String} + */ + + function getOuterHTML(el) { + if (el.outerHTML) { + return el.outerHTML; + } else { + var container = document.createElement('div'); + container.appendChild(el.cloneNode(true)); + return container.innerHTML; + } + } + + var commonTagRE = /^(div|p|span|img|a|b|i|br|ul|ol|li|h1|h2|h3|h4|h5|h6|code|pre|table|th|td|tr|form|label|input|select|option|nav|article|section|header|footer)$/i; + var reservedTagRE = /^(slot|partial|component)$/i; + + var isUnknownElement = undefined; + if ('development' !== 'production') { + isUnknownElement = function (el, tag) { + if (tag.indexOf('-') > -1) { + // http://stackoverflow.com/a/28210364/1070244 + return el.constructor === window.HTMLUnknownElement || el.constructor === window.HTMLElement; + } else { + return (/HTMLUnknownElement/.test(el.toString()) && + // Chrome returns unknown for several HTML5 elements. + // https://code.google.com/p/chromium/issues/detail?id=540526 + // Firefox returns unknown for some "Interactive elements." + !/^(data|time|rtc|rb|details|dialog|summary)$/.test(tag) + ); + } + }; + } + + /** + * Check if an element is a component, if yes return its + * component id. + * + * @param {Element} el + * @param {Object} options + * @return {Object|undefined} + */ + + function checkComponentAttr(el, options) { + var tag = el.tagName.toLowerCase(); + var hasAttrs = el.hasAttributes(); + if (!commonTagRE.test(tag) && !reservedTagRE.test(tag)) { + if (resolveAsset(options, 'components', tag)) { + return { id: tag }; + } else { + var is = hasAttrs && getIsBinding(el, options); + if (is) { + return is; + } else if ('development' !== 'production') { + var expectedTag = options._componentNameMap && options._componentNameMap[tag]; + if (expectedTag) { + warn('Unknown custom element: <' + tag + '> - ' + 'did you mean <' + expectedTag + '>? ' + 'HTML is case-insensitive, remember to use kebab-case in templates.'); + } else if (isUnknownElement(el, tag)) { + warn('Unknown custom element: <' + tag + '> - did you ' + 'register the component correctly? For recursive components, ' + 'make sure to provide the "name" option.'); + } + } + } + } else if (hasAttrs) { + return getIsBinding(el, options); + } + } + + /** + * Get "is" binding from an element. + * + * @param {Element} el + * @param {Object} options + * @return {Object|undefined} + */ + + function getIsBinding(el, options) { + // dynamic syntax + var exp = el.getAttribute('is'); + if (exp != null) { + if (resolveAsset(options, 'components', exp)) { + el.removeAttribute('is'); + return { id: exp }; + } + } else { + exp = getBindAttr(el, 'is'); + if (exp != null) { + return { id: exp, dynamic: true }; + } + } + } + + /** + * Option overwriting strategies are functions that handle + * how to merge a parent option value and a child option + * value into the final value. + * + * All strategy functions follow the same signature: + * + * @param {*} parentVal + * @param {*} childVal + * @param {Vue} [vm] + */ + + var strats = config.optionMergeStrategies = Object.create(null); + + /** + * Helper that recursively merges two data objects together. + */ + + function mergeData(to, from) { + var key, toVal, fromVal; + for (key in from) { + toVal = to[key]; + fromVal = from[key]; + if (!hasOwn(to, key)) { + set(to, key, fromVal); + } else if (isObject(toVal) && isObject(fromVal)) { + mergeData(toVal, fromVal); + } + } + return to; + } + + /** + * Data + */ + + strats.data = function (parentVal, childVal, vm) { + if (!vm) { + // in a Vue.extend merge, both should be functions + if (!childVal) { + return parentVal; + } + if (typeof childVal !== 'function') { + 'development' !== 'production' && warn('The "data" option should be a function ' + 'that returns a per-instance value in component ' + 'definitions.', vm); + return parentVal; + } + if (!parentVal) { + return childVal; + } + // when parentVal & childVal are both present, + // we need to return a function that returns the + // merged result of both functions... no need to + // check if parentVal is a function here because + // it has to be a function to pass previous merges. + return function mergedDataFn() { + return mergeData(childVal.call(this), parentVal.call(this)); + }; + } else if (parentVal || childVal) { + return function mergedInstanceDataFn() { + // instance merge + var instanceData = typeof childVal === 'function' ? childVal.call(vm) : childVal; + var defaultData = typeof parentVal === 'function' ? parentVal.call(vm) : undefined; + if (instanceData) { + return mergeData(instanceData, defaultData); + } else { + return defaultData; + } + }; + } + }; + + /** + * El + */ + + strats.el = function (parentVal, childVal, vm) { + if (!vm && childVal && typeof childVal !== 'function') { + 'development' !== 'production' && warn('The "el" option should be a function ' + 'that returns a per-instance value in component ' + 'definitions.', vm); + return; + } + var ret = childVal || parentVal; + // invoke the element factory if this is instance merge + return vm && typeof ret === 'function' ? ret.call(vm) : ret; + }; + + /** + * Hooks and param attributes are merged as arrays. + */ + + strats.init = strats.created = strats.ready = strats.attached = strats.detached = strats.beforeCompile = strats.compiled = strats.beforeDestroy = strats.destroyed = strats.activate = function (parentVal, childVal) { + return childVal ? parentVal ? parentVal.concat(childVal) : isArray(childVal) ? childVal : [childVal] : parentVal; + }; + + /** + * Assets + * + * When a vm is present (instance creation), we need to do + * a three-way merge between constructor options, instance + * options and parent options. + */ + + function mergeAssets(parentVal, childVal) { + var res = Object.create(parentVal || null); + return childVal ? extend(res, guardArrayAssets(childVal)) : res; + } + + config._assetTypes.forEach(function (type) { + strats[type + 's'] = mergeAssets; + }); + + /** + * Events & Watchers. + * + * Events & watchers hashes should not overwrite one + * another, so we merge them as arrays. + */ + + strats.watch = strats.events = function (parentVal, childVal) { + if (!childVal) return parentVal; + if (!parentVal) return childVal; + var ret = {}; + extend(ret, parentVal); + for (var key in childVal) { + var parent = ret[key]; + var child = childVal[key]; + if (parent && !isArray(parent)) { + parent = [parent]; + } + ret[key] = parent ? parent.concat(child) : [child]; + } + return ret; + }; + + /** + * Other object hashes. + */ + + strats.props = strats.methods = strats.computed = function (parentVal, childVal) { + if (!childVal) return parentVal; + if (!parentVal) return childVal; + var ret = Object.create(null); + extend(ret, parentVal); + extend(ret, childVal); + return ret; + }; + + /** + * Default strategy. + */ + + var defaultStrat = function defaultStrat(parentVal, childVal) { + return childVal === undefined ? parentVal : childVal; + }; + + /** + * Make sure component options get converted to actual + * constructors. + * + * @param {Object} options + */ + + function guardComponents(options) { + if (options.components) { + var components = options.components = guardArrayAssets(options.components); + var ids = Object.keys(components); + var def; + if ('development' !== 'production') { + var map = options._componentNameMap = {}; + } + for (var i = 0, l = ids.length; i < l; i++) { + var key = ids[i]; + if (commonTagRE.test(key) || reservedTagRE.test(key)) { + 'development' !== 'production' && warn('Do not use built-in or reserved HTML elements as component ' + 'id: ' + key); + continue; + } + // record a all lowercase <-> kebab-case mapping for + // possible custom element case error warning + if ('development' !== 'production') { + map[key.replace(/-/g, '').toLowerCase()] = hyphenate(key); + } + def = components[key]; + if (isPlainObject(def)) { + components[key] = Vue.extend(def); + } + } + } + } + + /** + * Ensure all props option syntax are normalized into the + * Object-based format. + * + * @param {Object} options + */ + + function guardProps(options) { + var props = options.props; + var i, val; + if (isArray(props)) { + options.props = {}; + i = props.length; + while (i--) { + val = props[i]; + if (typeof val === 'string') { + options.props[val] = null; + } else if (val.name) { + options.props[val.name] = val; + } + } + } else if (isPlainObject(props)) { + var keys = Object.keys(props); + i = keys.length; + while (i--) { + val = props[keys[i]]; + if (typeof val === 'function') { + props[keys[i]] = { type: val }; + } + } + } + } + + /** + * Guard an Array-format assets option and converted it + * into the key-value Object format. + * + * @param {Object|Array} assets + * @return {Object} + */ + + function guardArrayAssets(assets) { + if (isArray(assets)) { + var res = {}; + var i = assets.length; + var asset; + while (i--) { + asset = assets[i]; + var id = typeof asset === 'function' ? asset.options && asset.options.name || asset.id : asset.name || asset.id; + if (!id) { + 'development' !== 'production' && warn('Array-syntax assets must provide a "name" or "id" field.'); + } else { + res[id] = asset; + } + } + return res; + } + return assets; + } + + /** + * Merge two option objects into a new one. + * Core utility used in both instantiation and inheritance. + * + * @param {Object} parent + * @param {Object} child + * @param {Vue} [vm] - if vm is present, indicates this is + * an instantiation merge. + */ + + function mergeOptions(parent, child, vm) { + guardComponents(child); + guardProps(child); + if ('development' !== 'production') { + if (child.propsData && !vm) { + warn('propsData can only be used as an instantiation option.'); + } + } + var options = {}; + var key; + if (child['extends']) { + parent = typeof child['extends'] === 'function' ? mergeOptions(parent, child['extends'].options, vm) : mergeOptions(parent, child['extends'], vm); + } + if (child.mixins) { + for (var i = 0, l = child.mixins.length; i < l; i++) { + var mixin = child.mixins[i]; + var mixinOptions = mixin.prototype instanceof Vue ? mixin.options : mixin; + parent = mergeOptions(parent, mixinOptions, vm); + } + } + for (key in parent) { + mergeField(key); + } + for (key in child) { + if (!hasOwn(parent, key)) { + mergeField(key); + } + } + function mergeField(key) { + var strat = strats[key] || defaultStrat; + options[key] = strat(parent[key], child[key], vm, key); + } + return options; + } + + /** + * Resolve an asset. + * This function is used because child instances need access + * to assets defined in its ancestor chain. + * + * @param {Object} options + * @param {String} type + * @param {String} id + * @param {Boolean} warnMissing + * @return {Object|Function} + */ + + function resolveAsset(options, type, id, warnMissing) { + /* istanbul ignore if */ + if (typeof id !== 'string') { + return; + } + var assets = options[type]; + var camelizedId; + var res = assets[id] || + // camelCase ID + assets[camelizedId = camelize(id)] || + // Pascal Case ID + assets[camelizedId.charAt(0).toUpperCase() + camelizedId.slice(1)]; + if ('development' !== 'production' && warnMissing && !res) { + warn('Failed to resolve ' + type.slice(0, -1) + ': ' + id, options); + } + return res; + } + + var uid$1 = 0; + + /** + * A dep is an observable that can have multiple + * directives subscribing to it. + * + * @constructor + */ + function Dep() { + this.id = uid$1++; + this.subs = []; + } + + // the current target watcher being evaluated. + // this is globally unique because there could be only one + // watcher being evaluated at any time. + Dep.target = null; + + /** + * Add a directive subscriber. + * + * @param {Directive} sub + */ + + Dep.prototype.addSub = function (sub) { + this.subs.push(sub); + }; + + /** + * Remove a directive subscriber. + * + * @param {Directive} sub + */ + + Dep.prototype.removeSub = function (sub) { + this.subs.$remove(sub); + }; + + /** + * Add self as a dependency to the target watcher. + */ + + Dep.prototype.depend = function () { + Dep.target.addDep(this); + }; + + /** + * Notify all subscribers of a new value. + */ + + Dep.prototype.notify = function () { + // stablize the subscriber list first + var subs = toArray(this.subs); + for (var i = 0, l = subs.length; i < l; i++) { + subs[i].update(); + } + }; + + var arrayProto = Array.prototype; + var arrayMethods = Object.create(arrayProto) + + /** + * Intercept mutating methods and emit events + */ + + ;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) { + // cache original method + var original = arrayProto[method]; + def(arrayMethods, method, function mutator() { + // avoid leaking arguments: + // http://jsperf.com/closure-with-arguments + var i = arguments.length; + var args = new Array(i); + while (i--) { + args[i] = arguments[i]; + } + var result = original.apply(this, args); + var ob = this.__ob__; + var inserted; + switch (method) { + case 'push': + inserted = args; + break; + case 'unshift': + inserted = args; + break; + case 'splice': + inserted = args.slice(2); + break; + } + if (inserted) ob.observeArray(inserted); + // notify change + ob.dep.notify(); + return result; + }); + }); + + /** + * Swap the element at the given index with a new value + * and emits corresponding event. + * + * @param {Number} index + * @param {*} val + * @return {*} - replaced element + */ + + def(arrayProto, '$set', function $set(index, val) { + if (index >= this.length) { + this.length = Number(index) + 1; + } + return this.splice(index, 1, val)[0]; + }); + + /** + * Convenience method to remove the element at given index or target element reference. + * + * @param {*} item + */ + + def(arrayProto, '$remove', function $remove(item) { + /* istanbul ignore if */ + if (!this.length) return; + var index = indexOf(this, item); + if (index > -1) { + return this.splice(index, 1); + } + }); + + var arrayKeys = Object.getOwnPropertyNames(arrayMethods); + + /** + * By default, when a reactive property is set, the new value is + * also converted to become reactive. However in certain cases, e.g. + * v-for scope alias and props, we don't want to force conversion + * because the value may be a nested value under a frozen data structure. + * + * So whenever we want to set a reactive property without forcing + * conversion on the new value, we wrap that call inside this function. + */ + + var shouldConvert = true; + + function withoutConversion(fn) { + shouldConvert = false; + fn(); + shouldConvert = true; + } + + /** + * Observer class that are attached to each observed + * object. Once attached, the observer converts target + * object's property keys into getter/setters that + * collect dependencies and dispatches updates. + * + * @param {Array|Object} value + * @constructor + */ + + function Observer(value) { + this.value = value; + this.dep = new Dep(); + def(value, '__ob__', this); + if (isArray(value)) { + var augment = hasProto ? protoAugment : copyAugment; + augment(value, arrayMethods, arrayKeys); + this.observeArray(value); + } else { + this.walk(value); + } + } + + // Instance methods + + /** + * Walk through each property and convert them into + * getter/setters. This method should only be called when + * value type is Object. + * + * @param {Object} obj + */ + + Observer.prototype.walk = function (obj) { + var keys = Object.keys(obj); + for (var i = 0, l = keys.length; i < l; i++) { + this.convert(keys[i], obj[keys[i]]); + } + }; + + /** + * Observe a list of Array items. + * + * @param {Array} items + */ + + Observer.prototype.observeArray = function (items) { + for (var i = 0, l = items.length; i < l; i++) { + observe(items[i]); + } + }; + + /** + * Convert a property into getter/setter so we can emit + * the events when the property is accessed/changed. + * + * @param {String} key + * @param {*} val + */ + + Observer.prototype.convert = function (key, val) { + defineReactive(this.value, key, val); + }; + + /** + * Add an owner vm, so that when $set/$delete mutations + * happen we can notify owner vms to proxy the keys and + * digest the watchers. This is only called when the object + * is observed as an instance's root $data. + * + * @param {Vue} vm + */ + + Observer.prototype.addVm = function (vm) { + (this.vms || (this.vms = [])).push(vm); + }; + + /** + * Remove an owner vm. This is called when the object is + * swapped out as an instance's $data object. + * + * @param {Vue} vm + */ + + Observer.prototype.removeVm = function (vm) { + this.vms.$remove(vm); + }; + + // helpers + + /** + * Augment an target Object or Array by intercepting + * the prototype chain using __proto__ + * + * @param {Object|Array} target + * @param {Object} src + */ + + function protoAugment(target, src) { + /* eslint-disable no-proto */ + target.__proto__ = src; + /* eslint-enable no-proto */ + } + + /** + * Augment an target Object or Array by defining + * hidden properties. + * + * @param {Object|Array} target + * @param {Object} proto + */ + + function copyAugment(target, src, keys) { + for (var i = 0, l = keys.length; i < l; i++) { + var key = keys[i]; + def(target, key, src[key]); + } + } + + /** + * Attempt to create an observer instance for a value, + * returns the new observer if successfully observed, + * or the existing observer if the value already has one. + * + * @param {*} value + * @param {Vue} [vm] + * @return {Observer|undefined} + * @static + */ + + function observe(value, vm) { + if (!value || typeof value !== 'object') { + return; + } + var ob; + if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { + ob = value.__ob__; + } else if (shouldConvert && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue) { + ob = new Observer(value); + } + if (ob && vm) { + ob.addVm(vm); + } + return ob; + } + + /** + * Define a reactive property on an Object. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + */ + + function defineReactive(obj, key, val) { + var dep = new Dep(); + + var property = Object.getOwnPropertyDescriptor(obj, key); + if (property && property.configurable === false) { + return; + } + + // cater for pre-defined getter/setters + var getter = property && property.get; + var setter = property && property.set; + + var childOb = observe(val); + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + get: function reactiveGetter() { + var value = getter ? getter.call(obj) : val; + if (Dep.target) { + dep.depend(); + if (childOb) { + childOb.dep.depend(); + } + if (isArray(value)) { + for (var e, i = 0, l = value.length; i < l; i++) { + e = value[i]; + e && e.__ob__ && e.__ob__.dep.depend(); + } + } + } + return value; + }, + set: function reactiveSetter(newVal) { + var value = getter ? getter.call(obj) : val; + if (newVal === value) { + return; + } + if (setter) { + setter.call(obj, newVal); + } else { + val = newVal; + } + childOb = observe(newVal); + dep.notify(); + } + }); + } + + + + var util = Object.freeze({ + defineReactive: defineReactive, + set: set, + del: del, + hasOwn: hasOwn, + isLiteral: isLiteral, + isReserved: isReserved, + _toString: _toString, + toNumber: toNumber, + toBoolean: toBoolean, + stripQuotes: stripQuotes, + camelize: camelize, + hyphenate: hyphenate, + classify: classify, + bind: bind, + toArray: toArray, + extend: extend, + isObject: isObject, + isPlainObject: isPlainObject, + def: def, + debounce: _debounce, + indexOf: indexOf, + cancellable: cancellable, + looseEqual: looseEqual, + isArray: isArray, + hasProto: hasProto, + inBrowser: inBrowser, + devtools: devtools, + isIE: isIE, + isIE9: isIE9, + isAndroid: isAndroid, + isIos: isIos, + iosVersionMatch: iosVersionMatch, + iosVersion: iosVersion, + hasMutationObserverBug: hasMutationObserverBug, + get transitionProp () { return transitionProp; }, + get transitionEndEvent () { return transitionEndEvent; }, + get animationProp () { return animationProp; }, + get animationEndEvent () { return animationEndEvent; }, + nextTick: nextTick, + get _Set () { return _Set; }, + query: query, + inDoc: inDoc, + getAttr: getAttr, + getBindAttr: getBindAttr, + hasBindAttr: hasBindAttr, + before: before, + after: after, + remove: remove, + prepend: prepend, + replace: replace, + on: on, + off: off, + setClass: setClass, + addClass: addClass, + removeClass: removeClass, + extractContent: extractContent, + trimNode: trimNode, + isTemplate: isTemplate, + createAnchor: createAnchor, + findRef: findRef, + mapNodeRange: mapNodeRange, + removeNodeRange: removeNodeRange, + isFragment: isFragment, + getOuterHTML: getOuterHTML, + mergeOptions: mergeOptions, + resolveAsset: resolveAsset, + checkComponentAttr: checkComponentAttr, + commonTagRE: commonTagRE, + reservedTagRE: reservedTagRE, + get warn () { return warn; } + }); + + var uid = 0; + + function initMixin (Vue) { + /** + * The main init sequence. This is called for every + * instance, including ones that are created from extended + * constructors. + * + * @param {Object} options - this options object should be + * the result of merging class + * options and the options passed + * in to the constructor. + */ + + Vue.prototype._init = function (options) { + options = options || {}; + + this.$el = null; + this.$parent = options.parent; + this.$root = this.$parent ? this.$parent.$root : this; + this.$children = []; + this.$refs = {}; // child vm references + this.$els = {}; // element references + this._watchers = []; // all watchers as an array + this._directives = []; // all directives + + // a uid + this._uid = uid++; + + // a flag to avoid this being observed + this._isVue = true; + + // events bookkeeping + this._events = {}; // registered callbacks + this._eventsCount = {}; // for $broadcast optimization + + // fragment instance properties + this._isFragment = false; + this._fragment = // @type {DocumentFragment} + this._fragmentStart = // @type {Text|Comment} + this._fragmentEnd = null; // @type {Text|Comment} + + // lifecycle state + this._isCompiled = this._isDestroyed = this._isReady = this._isAttached = this._isBeingDestroyed = this._vForRemoving = false; + this._unlinkFn = null; + + // context: + // if this is a transcluded component, context + // will be the common parent vm of this instance + // and its host. + this._context = options._context || this.$parent; + + // scope: + // if this is inside an inline v-for, the scope + // will be the intermediate scope created for this + // repeat fragment. this is used for linking props + // and container directives. + this._scope = options._scope; + + // fragment: + // if this instance is compiled inside a Fragment, it + // needs to reigster itself as a child of that fragment + // for attach/detach to work properly. + this._frag = options._frag; + if (this._frag) { + this._frag.children.push(this); + } + + // push self into parent / transclusion host + if (this.$parent) { + this.$parent.$children.push(this); + } + + // merge options. + options = this.$options = mergeOptions(this.constructor.options, options, this); + + // set ref + this._updateRef(); + + // initialize data as empty object. + // it will be filled up in _initData(). + this._data = {}; + + // call init hook + this._callHook('init'); + + // initialize data observation and scope inheritance. + this._initState(); + + // setup event system and option events. + this._initEvents(); + + // call created hook + this._callHook('created'); + + // if `el` option is passed, start compilation. + if (options.el) { + this.$mount(options.el); + } + }; + } + + var pathCache = new Cache(1000); + + // actions + var APPEND = 0; + var PUSH = 1; + var INC_SUB_PATH_DEPTH = 2; + var PUSH_SUB_PATH = 3; + + // states + var BEFORE_PATH = 0; + var IN_PATH = 1; + var BEFORE_IDENT = 2; + var IN_IDENT = 3; + var IN_SUB_PATH = 4; + var IN_SINGLE_QUOTE = 5; + var IN_DOUBLE_QUOTE = 6; + var AFTER_PATH = 7; + var ERROR = 8; + + var pathStateMachine = []; + + pathStateMachine[BEFORE_PATH] = { + 'ws': [BEFORE_PATH], + 'ident': [IN_IDENT, APPEND], + '[': [IN_SUB_PATH], + 'eof': [AFTER_PATH] + }; + + pathStateMachine[IN_PATH] = { + 'ws': [IN_PATH], + '.': [BEFORE_IDENT], + '[': [IN_SUB_PATH], + 'eof': [AFTER_PATH] + }; + + pathStateMachine[BEFORE_IDENT] = { + 'ws': [BEFORE_IDENT], + 'ident': [IN_IDENT, APPEND] + }; + + pathStateMachine[IN_IDENT] = { + 'ident': [IN_IDENT, APPEND], + '0': [IN_IDENT, APPEND], + 'number': [IN_IDENT, APPEND], + 'ws': [IN_PATH, PUSH], + '.': [BEFORE_IDENT, PUSH], + '[': [IN_SUB_PATH, PUSH], + 'eof': [AFTER_PATH, PUSH] + }; + + pathStateMachine[IN_SUB_PATH] = { + "'": [IN_SINGLE_QUOTE, APPEND], + '"': [IN_DOUBLE_QUOTE, APPEND], + '[': [IN_SUB_PATH, INC_SUB_PATH_DEPTH], + ']': [IN_PATH, PUSH_SUB_PATH], + 'eof': ERROR, + 'else': [IN_SUB_PATH, APPEND] + }; + + pathStateMachine[IN_SINGLE_QUOTE] = { + "'": [IN_SUB_PATH, APPEND], + 'eof': ERROR, + 'else': [IN_SINGLE_QUOTE, APPEND] + }; + + pathStateMachine[IN_DOUBLE_QUOTE] = { + '"': [IN_SUB_PATH, APPEND], + 'eof': ERROR, + 'else': [IN_DOUBLE_QUOTE, APPEND] + }; + + /** + * Determine the type of a character in a keypath. + * + * @param {Char} ch + * @return {String} type + */ + + function getPathCharType(ch) { + if (ch === undefined) { + return 'eof'; + } + + var code = ch.charCodeAt(0); + + switch (code) { + case 0x5B: // [ + case 0x5D: // ] + case 0x2E: // . + case 0x22: // " + case 0x27: // ' + case 0x30: + // 0 + return ch; + + case 0x5F: // _ + case 0x24: + // $ + return 'ident'; + + case 0x20: // Space + case 0x09: // Tab + case 0x0A: // Newline + case 0x0D: // Return + case 0xA0: // No-break space + case 0xFEFF: // Byte Order Mark + case 0x2028: // Line Separator + case 0x2029: + // Paragraph Separator + return 'ws'; + } + + // a-z, A-Z + if (code >= 0x61 && code <= 0x7A || code >= 0x41 && code <= 0x5A) { + return 'ident'; + } + + // 1-9 + if (code >= 0x31 && code <= 0x39) { + return 'number'; + } + + return 'else'; + } + + /** + * Format a subPath, return its plain form if it is + * a literal string or number. Otherwise prepend the + * dynamic indicator (*). + * + * @param {String} path + * @return {String} + */ + + function formatSubPath(path) { + var trimmed = path.trim(); + // invalid leading 0 + if (path.charAt(0) === '0' && isNaN(path)) { + return false; + } + return isLiteral(trimmed) ? stripQuotes(trimmed) : '*' + trimmed; + } + + /** + * Parse a string path into an array of segments + * + * @param {String} path + * @return {Array|undefined} + */ + + function parse(path) { + var keys = []; + var index = -1; + var mode = BEFORE_PATH; + var subPathDepth = 0; + var c, newChar, key, type, transition, action, typeMap; + + var actions = []; + + actions[PUSH] = function () { + if (key !== undefined) { + keys.push(key); + key = undefined; + } + }; + + actions[APPEND] = function () { + if (key === undefined) { + key = newChar; + } else { + key += newChar; + } + }; + + actions[INC_SUB_PATH_DEPTH] = function () { + actions[APPEND](); + subPathDepth++; + }; + + actions[PUSH_SUB_PATH] = function () { + if (subPathDepth > 0) { + subPathDepth--; + mode = IN_SUB_PATH; + actions[APPEND](); + } else { + subPathDepth = 0; + key = formatSubPath(key); + if (key === false) { + return false; + } else { + actions[PUSH](); + } + } + }; + + function maybeUnescapeQuote() { + var nextChar = path[index + 1]; + if (mode === IN_SINGLE_QUOTE && nextChar === "'" || mode === IN_DOUBLE_QUOTE && nextChar === '"') { + index++; + newChar = '\\' + nextChar; + actions[APPEND](); + return true; + } + } + + while (mode != null) { + index++; + c = path[index]; + + if (c === '\\' && maybeUnescapeQuote()) { + continue; + } + + type = getPathCharType(c); + typeMap = pathStateMachine[mode]; + transition = typeMap[type] || typeMap['else'] || ERROR; + + if (transition === ERROR) { + return; // parse error + } + + mode = transition[0]; + action = actions[transition[1]]; + if (action) { + newChar = transition[2]; + newChar = newChar === undefined ? c : newChar; + if (action() === false) { + return; + } + } + + if (mode === AFTER_PATH) { + keys.raw = path; + return keys; + } + } + } + + /** + * External parse that check for a cache hit first + * + * @param {String} path + * @return {Array|undefined} + */ + + function parsePath(path) { + var hit = pathCache.get(path); + if (!hit) { + hit = parse(path); + if (hit) { + pathCache.put(path, hit); + } + } + return hit; + } + + /** + * Get from an object from a path string + * + * @param {Object} obj + * @param {String} path + */ + + function getPath(obj, path) { + return parseExpression(path).get(obj); + } + + /** + * Warn against setting non-existent root path on a vm. + */ + + var warnNonExistent; + if ('development' !== 'production') { + warnNonExistent = function (path, vm) { + warn('You are setting a non-existent path "' + path.raw + '" ' + 'on a vm instance. Consider pre-initializing the property ' + 'with the "data" option for more reliable reactivity ' + 'and better performance.', vm); + }; + } + + /** + * Set on an object from a path + * + * @param {Object} obj + * @param {String | Array} path + * @param {*} val + */ + + function setPath(obj, path, val) { + var original = obj; + if (typeof path === 'string') { + path = parse(path); + } + if (!path || !isObject(obj)) { + return false; + } + var last, key; + for (var i = 0, l = path.length; i < l; i++) { + last = obj; + key = path[i]; + if (key.charAt(0) === '*') { + key = parseExpression(key.slice(1)).get.call(original, original); + } + if (i < l - 1) { + obj = obj[key]; + if (!isObject(obj)) { + obj = {}; + if ('development' !== 'production' && last._isVue) { + warnNonExistent(path, last); + } + set(last, key, obj); + } + } else { + if (isArray(obj)) { + obj.$set(key, val); + } else if (key in obj) { + obj[key] = val; + } else { + if ('development' !== 'production' && obj._isVue) { + warnNonExistent(path, obj); + } + set(obj, key, val); + } + } + } + return true; + } + +var path = Object.freeze({ + parsePath: parsePath, + getPath: getPath, + setPath: setPath + }); + + var expressionCache = new Cache(1000); + + var allowedKeywords = 'Math,Date,this,true,false,null,undefined,Infinity,NaN,' + 'isNaN,isFinite,decodeURI,decodeURIComponent,encodeURI,' + 'encodeURIComponent,parseInt,parseFloat'; + var allowedKeywordsRE = new RegExp('^(' + allowedKeywords.replace(/,/g, '\\b|') + '\\b)'); + + // keywords that don't make sense inside expressions + var improperKeywords = 'break,case,class,catch,const,continue,debugger,default,' + 'delete,do,else,export,extends,finally,for,function,if,' + 'import,in,instanceof,let,return,super,switch,throw,try,' + 'var,while,with,yield,enum,await,implements,package,' + 'protected,static,interface,private,public'; + var improperKeywordsRE = new RegExp('^(' + improperKeywords.replace(/,/g, '\\b|') + '\\b)'); + + var wsRE = /\s/g; + var newlineRE = /\n/g; + var saveRE = /[\{,]\s*[\w\$_]+\s*:|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*\$\{|\}(?:[^`\\]|\\.)*`|`(?:[^`\\]|\\.)*`)|new |typeof |void /g; + var restoreRE = /"(\d+)"/g; + var pathTestRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['.*?'\]|\[".*?"\]|\[\d+\]|\[[A-Za-z_$][\w$]*\])*$/; + var identRE = /[^\w$\.](?:[A-Za-z_$][\w$]*)/g; + var literalValueRE$1 = /^(?:true|false|null|undefined|Infinity|NaN)$/; + + function noop() {} + + /** + * Save / Rewrite / Restore + * + * When rewriting paths found in an expression, it is + * possible for the same letter sequences to be found in + * strings and Object literal property keys. Therefore we + * remove and store these parts in a temporary array, and + * restore them after the path rewrite. + */ + + var saved = []; + + /** + * Save replacer + * + * The save regex can match two possible cases: + * 1. An opening object literal + * 2. A string + * If matched as a plain string, we need to escape its + * newlines, since the string needs to be preserved when + * generating the function body. + * + * @param {String} str + * @param {String} isString - str if matched as a string + * @return {String} - placeholder with index + */ + + function save(str, isString) { + var i = saved.length; + saved[i] = isString ? str.replace(newlineRE, '\\n') : str; + return '"' + i + '"'; + } + + /** + * Path rewrite replacer + * + * @param {String} raw + * @return {String} + */ + + function rewrite(raw) { + var c = raw.charAt(0); + var path = raw.slice(1); + if (allowedKeywordsRE.test(path)) { + return raw; + } else { + path = path.indexOf('"') > -1 ? path.replace(restoreRE, restore) : path; + return c + 'scope.' + path; + } + } + + /** + * Restore replacer + * + * @param {String} str + * @param {String} i - matched save index + * @return {String} + */ + + function restore(str, i) { + return saved[i]; + } + + /** + * Rewrite an expression, prefixing all path accessors with + * `scope.` and generate getter/setter functions. + * + * @param {String} exp + * @return {Function} + */ + + function compileGetter(exp) { + if (improperKeywordsRE.test(exp)) { + 'development' !== 'production' && warn('Avoid using reserved keywords in expression: ' + exp); + } + // reset state + saved.length = 0; + // save strings and object literal keys + var body = exp.replace(saveRE, save).replace(wsRE, ''); + // rewrite all paths + // pad 1 space here because the regex matches 1 extra char + body = (' ' + body).replace(identRE, rewrite).replace(restoreRE, restore); + return makeGetterFn(body); + } + + /** + * Build a getter function. Requires eval. + * + * We isolate the try/catch so it doesn't affect the + * optimization of the parse function when it is not called. + * + * @param {String} body + * @return {Function|undefined} + */ + + function makeGetterFn(body) { + try { + /* eslint-disable no-new-func */ + return new Function('scope', 'return ' + body + ';'); + /* eslint-enable no-new-func */ + } catch (e) { + if ('development' !== 'production') { + /* istanbul ignore if */ + if (e.toString().match(/unsafe-eval|CSP/)) { + warn('It seems you are using the default build of Vue.js in an environment ' + 'with Content Security Policy that prohibits unsafe-eval. ' + 'Use the CSP-compliant build instead: ' + 'http://vuejs.org/guide/installation.html#CSP-compliant-build'); + } else { + warn('Invalid expression. ' + 'Generated function body: ' + body); + } + } + return noop; + } + } + + /** + * Compile a setter function for the expression. + * + * @param {String} exp + * @return {Function|undefined} + */ + + function compileSetter(exp) { + var path = parsePath(exp); + if (path) { + return function (scope, val) { + setPath(scope, path, val); + }; + } else { + 'development' !== 'production' && warn('Invalid setter expression: ' + exp); + } + } + + /** + * Parse an expression into re-written getter/setters. + * + * @param {String} exp + * @param {Boolean} needSet + * @return {Function} + */ + + function parseExpression(exp, needSet) { + exp = exp.trim(); + // try cache + var hit = expressionCache.get(exp); + if (hit) { + if (needSet && !hit.set) { + hit.set = compileSetter(hit.exp); + } + return hit; + } + var res = { exp: exp }; + res.get = isSimplePath(exp) && exp.indexOf('[') < 0 + // optimized super simple getter + ? makeGetterFn('scope.' + exp) + // dynamic getter + : compileGetter(exp); + if (needSet) { + res.set = compileSetter(exp); + } + expressionCache.put(exp, res); + return res; + } + + /** + * Check if an expression is a simple path. + * + * @param {String} exp + * @return {Boolean} + */ + + function isSimplePath(exp) { + return pathTestRE.test(exp) && + // don't treat literal values as paths + !literalValueRE$1.test(exp) && + // Math constants e.g. Math.PI, Math.E etc. + exp.slice(0, 5) !== 'Math.'; + } + +var expression = Object.freeze({ + parseExpression: parseExpression, + isSimplePath: isSimplePath + }); + + // we have two separate queues: one for directive updates + // and one for user watcher registered via $watch(). + // we want to guarantee directive updates to be called + // before user watchers so that when user watchers are + // triggered, the DOM would have already been in updated + // state. + + var queue = []; + var userQueue = []; + var has = {}; + var circular = {}; + var waiting = false; + + /** + * Reset the batcher's state. + */ + + function resetBatcherState() { + queue.length = 0; + userQueue.length = 0; + has = {}; + circular = {}; + waiting = false; + } + + /** + * Flush both queues and run the watchers. + */ + + function flushBatcherQueue() { + var _again = true; + + _function: while (_again) { + _again = false; + + runBatcherQueue(queue); + runBatcherQueue(userQueue); + // user watchers triggered more watchers, + // keep flushing until it depletes + if (queue.length) { + _again = true; + continue _function; + } + // dev tool hook + /* istanbul ignore if */ + if (devtools && config.devtools) { + devtools.emit('flush'); + } + resetBatcherState(); + } + } + + /** + * Run the watchers in a single queue. + * + * @param {Array} queue + */ + + function runBatcherQueue(queue) { + // do not cache length because more watchers might be pushed + // as we run existing watchers + for (var i = 0; i < queue.length; i++) { + var watcher = queue[i]; + var id = watcher.id; + has[id] = null; + watcher.run(); + // in dev build, check and stop circular updates. + if ('development' !== 'production' && has[id] != null) { + circular[id] = (circular[id] || 0) + 1; + if (circular[id] > config._maxUpdateCount) { + warn('You may have an infinite update loop for watcher ' + 'with expression "' + watcher.expression + '"', watcher.vm); + break; + } + } + } + queue.length = 0; + } + + /** + * Push a watcher into the watcher queue. + * Jobs with duplicate IDs will be skipped unless it's + * pushed when the queue is being flushed. + * + * @param {Watcher} watcher + * properties: + * - {Number} id + * - {Function} run + */ + + function pushWatcher(watcher) { + var id = watcher.id; + if (has[id] == null) { + // push watcher into appropriate queue + var q = watcher.user ? userQueue : queue; + has[id] = q.length; + q.push(watcher); + // queue the flush + if (!waiting) { + waiting = true; + nextTick(flushBatcherQueue); + } + } + } + + var uid$2 = 0; + + /** + * A watcher parses an expression, collects dependencies, + * and fires callback when the expression value changes. + * This is used for both the $watch() api and directives. + * + * @param {Vue} vm + * @param {String|Function} expOrFn + * @param {Function} cb + * @param {Object} options + * - {Array} filters + * - {Boolean} twoWay + * - {Boolean} deep + * - {Boolean} user + * - {Boolean} sync + * - {Boolean} lazy + * - {Function} [preProcess] + * - {Function} [postProcess] + * @constructor + */ + function Watcher(vm, expOrFn, cb, options) { + // mix in options + if (options) { + extend(this, options); + } + var isFn = typeof expOrFn === 'function'; + this.vm = vm; + vm._watchers.push(this); + this.expression = expOrFn; + this.cb = cb; + this.id = ++uid$2; // uid for batching + this.active = true; + this.dirty = this.lazy; // for lazy watchers + this.deps = []; + this.newDeps = []; + this.depIds = new _Set(); + this.newDepIds = new _Set(); + this.prevError = null; // for async error stacks + // parse expression for getter/setter + if (isFn) { + this.getter = expOrFn; + this.setter = undefined; + } else { + var res = parseExpression(expOrFn, this.twoWay); + this.getter = res.get; + this.setter = res.set; + } + this.value = this.lazy ? undefined : this.get(); + // state for avoiding false triggers for deep and Array + // watchers during vm._digest() + this.queued = this.shallow = false; + } + + /** + * Evaluate the getter, and re-collect dependencies. + */ + + Watcher.prototype.get = function () { + this.beforeGet(); + var scope = this.scope || this.vm; + var value; + try { + value = this.getter.call(scope, scope); + } catch (e) { + if ('development' !== 'production' && config.warnExpressionErrors) { + warn('Error when evaluating expression ' + '"' + this.expression + '": ' + e.toString(), this.vm); + } + } + // "touch" every property so they are all tracked as + // dependencies for deep watching + if (this.deep) { + traverse(value); + } + if (this.preProcess) { + value = this.preProcess(value); + } + if (this.filters) { + value = scope._applyFilters(value, null, this.filters, false); + } + if (this.postProcess) { + value = this.postProcess(value); + } + this.afterGet(); + return value; + }; + + /** + * Set the corresponding value with the setter. + * + * @param {*} value + */ + + Watcher.prototype.set = function (value) { + var scope = this.scope || this.vm; + if (this.filters) { + value = scope._applyFilters(value, this.value, this.filters, true); + } + try { + this.setter.call(scope, scope, value); + } catch (e) { + if ('development' !== 'production' && config.warnExpressionErrors) { + warn('Error when evaluating setter ' + '"' + this.expression + '": ' + e.toString(), this.vm); + } + } + // two-way sync for v-for alias + var forContext = scope.$forContext; + if (forContext && forContext.alias === this.expression) { + if (forContext.filters) { + 'development' !== 'production' && warn('It seems you are using two-way binding on ' + 'a v-for alias (' + this.expression + '), and the ' + 'v-for has filters. This will not work properly. ' + 'Either remove the filters or use an array of ' + 'objects and bind to object properties instead.', this.vm); + return; + } + forContext._withLock(function () { + if (scope.$key) { + // original is an object + forContext.rawValue[scope.$key] = value; + } else { + forContext.rawValue.$set(scope.$index, value); + } + }); + } + }; + + /** + * Prepare for dependency collection. + */ + + Watcher.prototype.beforeGet = function () { + Dep.target = this; + }; + + /** + * Add a dependency to this directive. + * + * @param {Dep} dep + */ + + Watcher.prototype.addDep = function (dep) { + var id = dep.id; + if (!this.newDepIds.has(id)) { + this.newDepIds.add(id); + this.newDeps.push(dep); + if (!this.depIds.has(id)) { + dep.addSub(this); + } + } + }; + + /** + * Clean up for dependency collection. + */ + + Watcher.prototype.afterGet = function () { + Dep.target = null; + var i = this.deps.length; + while (i--) { + var dep = this.deps[i]; + if (!this.newDepIds.has(dep.id)) { + dep.removeSub(this); + } + } + var tmp = this.depIds; + this.depIds = this.newDepIds; + this.newDepIds = tmp; + this.newDepIds.clear(); + tmp = this.deps; + this.deps = this.newDeps; + this.newDeps = tmp; + this.newDeps.length = 0; + }; + + /** + * Subscriber interface. + * Will be called when a dependency changes. + * + * @param {Boolean} shallow + */ + + Watcher.prototype.update = function (shallow) { + if (this.lazy) { + this.dirty = true; + } else if (this.sync || !config.async) { + this.run(); + } else { + // if queued, only overwrite shallow with non-shallow, + // but not the other way around. + this.shallow = this.queued ? shallow ? this.shallow : false : !!shallow; + this.queued = true; + // record before-push error stack in debug mode + /* istanbul ignore if */ + if ('development' !== 'production' && config.debug) { + this.prevError = new Error('[vue] async stack trace'); + } + pushWatcher(this); + } + }; + + /** + * Batcher job interface. + * Will be called by the batcher. + */ + + Watcher.prototype.run = function () { + if (this.active) { + var value = this.get(); + if (value !== this.value || + // Deep watchers and watchers on Object/Arrays should fire even + // when the value is the same, because the value may + // have mutated; but only do so if this is a + // non-shallow update (caused by a vm digest). + (isObject(value) || this.deep) && !this.shallow) { + // set new value + var oldValue = this.value; + this.value = value; + // in debug + async mode, when a watcher callbacks + // throws, we also throw the saved before-push error + // so the full cross-tick stack trace is available. + var prevError = this.prevError; + /* istanbul ignore if */ + if ('development' !== 'production' && config.debug && prevError) { + this.prevError = null; + try { + this.cb.call(this.vm, value, oldValue); + } catch (e) { + nextTick(function () { + throw prevError; + }, 0); + throw e; + } + } else { + this.cb.call(this.vm, value, oldValue); + } + } + this.queued = this.shallow = false; + } + }; + + /** + * Evaluate the value of the watcher. + * This only gets called for lazy watchers. + */ + + Watcher.prototype.evaluate = function () { + // avoid overwriting another watcher that is being + // collected. + var current = Dep.target; + this.value = this.get(); + this.dirty = false; + Dep.target = current; + }; + + /** + * Depend on all deps collected by this watcher. + */ + + Watcher.prototype.depend = function () { + var i = this.deps.length; + while (i--) { + this.deps[i].depend(); + } + }; + + /** + * Remove self from all dependencies' subcriber list. + */ + + Watcher.prototype.teardown = function () { + if (this.active) { + // remove self from vm's watcher list + // this is a somewhat expensive operation so we skip it + // if the vm is being destroyed or is performing a v-for + // re-render (the watcher list is then filtered by v-for). + if (!this.vm._isBeingDestroyed && !this.vm._vForRemoving) { + this.vm._watchers.$remove(this); + } + var i = this.deps.length; + while (i--) { + this.deps[i].removeSub(this); + } + this.active = false; + this.vm = this.cb = this.value = null; + } + }; + + /** + * Recrusively traverse an object to evoke all converted + * getters, so that every nested property inside the object + * is collected as a "deep" dependency. + * + * @param {*} val + */ + + var seenObjects = new _Set(); + function traverse(val, seen) { + var i = undefined, + keys = undefined; + if (!seen) { + seen = seenObjects; + seen.clear(); + } + var isA = isArray(val); + var isO = isObject(val); + if ((isA || isO) && Object.isExtensible(val)) { + if (val.__ob__) { + var depId = val.__ob__.dep.id; + if (seen.has(depId)) { + return; + } else { + seen.add(depId); + } + } + if (isA) { + i = val.length; + while (i--) traverse(val[i], seen); + } else if (isO) { + keys = Object.keys(val); + i = keys.length; + while (i--) traverse(val[keys[i]], seen); + } + } + } + + var text$1 = { + + bind: function bind() { + this.attr = this.el.nodeType === 3 ? 'data' : 'textContent'; + }, + + update: function update(value) { + this.el[this.attr] = _toString(value); + } + }; + + var templateCache = new Cache(1000); + var idSelectorCache = new Cache(1000); + + var map = { + efault: [0, '', ''], + legend: [1, '<fieldset>', '</fieldset>'], + tr: [2, '<table><tbody>', '</tbody></table>'], + col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'] + }; + + map.td = map.th = [3, '<table><tbody><tr>', '</tr></tbody></table>']; + + map.option = map.optgroup = [1, '<select multiple="multiple">', '</select>']; + + map.thead = map.tbody = map.colgroup = map.caption = map.tfoot = [1, '<table>', '</table>']; + + map.g = map.defs = map.symbol = map.use = map.image = map.text = map.circle = map.ellipse = map.line = map.path = map.polygon = map.polyline = map.rect = [1, '<svg ' + 'xmlns="http://www.w3.org/2000/svg" ' + 'xmlns:xlink="http://www.w3.org/1999/xlink" ' + 'xmlns:ev="http://www.w3.org/2001/xml-events"' + 'version="1.1">', '</svg>']; + + /** + * Check if a node is a supported template node with a + * DocumentFragment content. + * + * @param {Node} node + * @return {Boolean} + */ + + function isRealTemplate(node) { + return isTemplate(node) && isFragment(node.content); + } + + var tagRE$1 = /<([\w:-]+)/; + var entityRE = /&#?\w+?;/; + var commentRE = /<!--/; + + /** + * Convert a string template to a DocumentFragment. + * Determines correct wrapping by tag types. Wrapping + * strategy found in jQuery & component/domify. + * + * @param {String} templateString + * @param {Boolean} raw + * @return {DocumentFragment} + */ + + function stringToFragment(templateString, raw) { + // try a cache hit first + var cacheKey = raw ? templateString : templateString.trim(); + var hit = templateCache.get(cacheKey); + if (hit) { + return hit; + } + + var frag = document.createDocumentFragment(); + var tagMatch = templateString.match(tagRE$1); + var entityMatch = entityRE.test(templateString); + var commentMatch = commentRE.test(templateString); + + if (!tagMatch && !entityMatch && !commentMatch) { + // text only, return a single text node. + frag.appendChild(document.createTextNode(templateString)); + } else { + var tag = tagMatch && tagMatch[1]; + var wrap = map[tag] || map.efault; + var depth = wrap[0]; + var prefix = wrap[1]; + var suffix = wrap[2]; + var node = document.createElement('div'); + + node.innerHTML = prefix + templateString + suffix; + while (depth--) { + node = node.lastChild; + } + + var child; + /* eslint-disable no-cond-assign */ + while (child = node.firstChild) { + /* eslint-enable no-cond-assign */ + frag.appendChild(child); + } + } + if (!raw) { + trimNode(frag); + } + templateCache.put(cacheKey, frag); + return frag; + } + + /** + * Convert a template node to a DocumentFragment. + * + * @param {Node} node + * @return {DocumentFragment} + */ + + function nodeToFragment(node) { + // if its a template tag and the browser supports it, + // its content is already a document fragment. However, iOS Safari has + // bug when using directly cloned template content with touch + // events and can cause crashes when the nodes are removed from DOM, so we + // have to treat template elements as string templates. (#2805) + /* istanbul ignore if */ + if (isRealTemplate(node)) { + return stringToFragment(node.innerHTML); + } + // script template + if (node.tagName === 'SCRIPT') { + return stringToFragment(node.textContent); + } + // normal node, clone it to avoid mutating the original + var clonedNode = cloneNode(node); + var frag = document.createDocumentFragment(); + var child; + /* eslint-disable no-cond-assign */ + while (child = clonedNode.firstChild) { + /* eslint-enable no-cond-assign */ + frag.appendChild(child); + } + trimNode(frag); + return frag; + } + + // Test for the presence of the Safari template cloning bug + // https://bugs.webkit.org/showug.cgi?id=137755 + var hasBrokenTemplate = (function () { + /* istanbul ignore else */ + if (inBrowser) { + var a = document.createElement('div'); + a.innerHTML = '<template>1</template>'; + return !a.cloneNode(true).firstChild.innerHTML; + } else { + return false; + } + })(); + + // Test for IE10/11 textarea placeholder clone bug + var hasTextareaCloneBug = (function () { + /* istanbul ignore else */ + if (inBrowser) { + var t = document.createElement('textarea'); + t.placeholder = 't'; + return t.cloneNode(true).value === 't'; + } else { + return false; + } + })(); + + /** + * 1. Deal with Safari cloning nested <template> bug by + * manually cloning all template instances. + * 2. Deal with IE10/11 textarea placeholder bug by setting + * the correct value after cloning. + * + * @param {Element|DocumentFragment} node + * @return {Element|DocumentFragment} + */ + + function cloneNode(node) { + /* istanbul ignore if */ + if (!node.querySelectorAll) { + return node.cloneNode(); + } + var res = node.cloneNode(true); + var i, original, cloned; + /* istanbul ignore if */ + if (hasBrokenTemplate) { + var tempClone = res; + if (isRealTemplate(node)) { + node = node.content; + tempClone = res.content; + } + original = node.querySelectorAll('template'); + if (original.length) { + cloned = tempClone.querySelectorAll('template'); + i = cloned.length; + while (i--) { + cloned[i].parentNode.replaceChild(cloneNode(original[i]), cloned[i]); + } + } + } + /* istanbul ignore if */ + if (hasTextareaCloneBug) { + if (node.tagName === 'TEXTAREA') { + res.value = node.value; + } else { + original = node.querySelectorAll('textarea'); + if (original.length) { + cloned = res.querySelectorAll('textarea'); + i = cloned.length; + while (i--) { + cloned[i].value = original[i].value; + } + } + } + } + return res; + } + + /** + * Process the template option and normalizes it into a + * a DocumentFragment that can be used as a partial or a + * instance template. + * + * @param {*} template + * Possible values include: + * - DocumentFragment object + * - Node object of type Template + * - id selector: '#some-template-id' + * - template string: '<div><span>{{msg}}</span></div>' + * @param {Boolean} shouldClone + * @param {Boolean} raw + * inline HTML interpolation. Do not check for id + * selector and keep whitespace in the string. + * @return {DocumentFragment|undefined} + */ + + function parseTemplate(template, shouldClone, raw) { + var node, frag; + + // if the template is already a document fragment, + // do nothing + if (isFragment(template)) { + trimNode(template); + return shouldClone ? cloneNode(template) : template; + } + + if (typeof template === 'string') { + // id selector + if (!raw && template.charAt(0) === '#') { + // id selector can be cached too + frag = idSelectorCache.get(template); + if (!frag) { + node = document.getElementById(template.slice(1)); + if (node) { + frag = nodeToFragment(node); + // save selector to cache + idSelectorCache.put(template, frag); + } + } + } else { + // normal string template + frag = stringToFragment(template, raw); + } + } else if (template.nodeType) { + // a direct node + frag = nodeToFragment(template); + } + + return frag && shouldClone ? cloneNode(frag) : frag; + } + +var template = Object.freeze({ + cloneNode: cloneNode, + parseTemplate: parseTemplate + }); + + var html = { + + bind: function bind() { + // a comment node means this is a binding for + // {{{ inline unescaped html }}} + if (this.el.nodeType === 8) { + // hold nodes + this.nodes = []; + // replace the placeholder with proper anchor + this.anchor = createAnchor('v-html'); + replace(this.el, this.anchor); + } + }, + + update: function update(value) { + value = _toString(value); + if (this.nodes) { + this.swap(value); + } else { + this.el.innerHTML = value; + } + }, + + swap: function swap(value) { + // remove old nodes + var i = this.nodes.length; + while (i--) { + remove(this.nodes[i]); + } + // convert new value to a fragment + // do not attempt to retrieve from id selector + var frag = parseTemplate(value, true, true); + // save a reference to these nodes so we can remove later + this.nodes = toArray(frag.childNodes); + before(frag, this.anchor); + } + }; + + /** + * Abstraction for a partially-compiled fragment. + * Can optionally compile content with a child scope. + * + * @param {Function} linker + * @param {Vue} vm + * @param {DocumentFragment} frag + * @param {Vue} [host] + * @param {Object} [scope] + * @param {Fragment} [parentFrag] + */ + function Fragment(linker, vm, frag, host, scope, parentFrag) { + this.children = []; + this.childFrags = []; + this.vm = vm; + this.scope = scope; + this.inserted = false; + this.parentFrag = parentFrag; + if (parentFrag) { + parentFrag.childFrags.push(this); + } + this.unlink = linker(vm, frag, host, scope, this); + var single = this.single = frag.childNodes.length === 1 && + // do not go single mode if the only node is an anchor + !frag.childNodes[0].__v_anchor; + if (single) { + this.node = frag.childNodes[0]; + this.before = singleBefore; + this.remove = singleRemove; + } else { + this.node = createAnchor('fragment-start'); + this.end = createAnchor('fragment-end'); + this.frag = frag; + prepend(this.node, frag); + frag.appendChild(this.end); + this.before = multiBefore; + this.remove = multiRemove; + } + this.node.__v_frag = this; + } + + /** + * Call attach/detach for all components contained within + * this fragment. Also do so recursively for all child + * fragments. + * + * @param {Function} hook + */ + + Fragment.prototype.callHook = function (hook) { + var i, l; + for (i = 0, l = this.childFrags.length; i < l; i++) { + this.childFrags[i].callHook(hook); + } + for (i = 0, l = this.children.length; i < l; i++) { + hook(this.children[i]); + } + }; + + /** + * Insert fragment before target, single node version + * + * @param {Node} target + * @param {Boolean} withTransition + */ + + function singleBefore(target, withTransition) { + this.inserted = true; + var method = withTransition !== false ? beforeWithTransition : before; + method(this.node, target, this.vm); + if (inDoc(this.node)) { + this.callHook(attach); + } + } + + /** + * Remove fragment, single node version + */ + + function singleRemove() { + this.inserted = false; + var shouldCallRemove = inDoc(this.node); + var self = this; + this.beforeRemove(); + removeWithTransition(this.node, this.vm, function () { + if (shouldCallRemove) { + self.callHook(detach); + } + self.destroy(); + }); + } + + /** + * Insert fragment before target, multi-nodes version + * + * @param {Node} target + * @param {Boolean} withTransition + */ + + function multiBefore(target, withTransition) { + this.inserted = true; + var vm = this.vm; + var method = withTransition !== false ? beforeWithTransition : before; + mapNodeRange(this.node, this.end, function (node) { + method(node, target, vm); + }); + if (inDoc(this.node)) { + this.callHook(attach); + } + } + + /** + * Remove fragment, multi-nodes version + */ + + function multiRemove() { + this.inserted = false; + var self = this; + var shouldCallRemove = inDoc(this.node); + this.beforeRemove(); + removeNodeRange(this.node, this.end, this.vm, this.frag, function () { + if (shouldCallRemove) { + self.callHook(detach); + } + self.destroy(); + }); + } + + /** + * Prepare the fragment for removal. + */ + + Fragment.prototype.beforeRemove = function () { + var i, l; + for (i = 0, l = this.childFrags.length; i < l; i++) { + // call the same method recursively on child + // fragments, depth-first + this.childFrags[i].beforeRemove(false); + } + for (i = 0, l = this.children.length; i < l; i++) { + // Call destroy for all contained instances, + // with remove:false and defer:true. + // Defer is necessary because we need to + // keep the children to call detach hooks + // on them. + this.children[i].$destroy(false, true); + } + var dirs = this.unlink.dirs; + for (i = 0, l = dirs.length; i < l; i++) { + // disable the watchers on all the directives + // so that the rendered content stays the same + // during removal. + dirs[i]._watcher && dirs[i]._watcher.teardown(); + } + }; + + /** + * Destroy the fragment. + */ + + Fragment.prototype.destroy = function () { + if (this.parentFrag) { + this.parentFrag.childFrags.$remove(this); + } + this.node.__v_frag = null; + this.unlink(); + }; + + /** + * Call attach hook for a Vue instance. + * + * @param {Vue} child + */ + + function attach(child) { + if (!child._isAttached && inDoc(child.$el)) { + child._callHook('attached'); + } + } + + /** + * Call detach hook for a Vue instance. + * + * @param {Vue} child + */ + + function detach(child) { + if (child._isAttached && !inDoc(child.$el)) { + child._callHook('detached'); + } + } + + var linkerCache = new Cache(5000); + + /** + * A factory that can be used to create instances of a + * fragment. Caches the compiled linker if possible. + * + * @param {Vue} vm + * @param {Element|String} el + */ + function FragmentFactory(vm, el) { + this.vm = vm; + var template; + var isString = typeof el === 'string'; + if (isString || isTemplate(el) && !el.hasAttribute('v-if')) { + template = parseTemplate(el, true); + } else { + template = document.createDocumentFragment(); + template.appendChild(el); + } + this.template = template; + // linker can be cached, but only for components + var linker; + var cid = vm.constructor.cid; + if (cid > 0) { + var cacheId = cid + (isString ? el : getOuterHTML(el)); + linker = linkerCache.get(cacheId); + if (!linker) { + linker = compile(template, vm.$options, true); + linkerCache.put(cacheId, linker); + } + } else { + linker = compile(template, vm.$options, true); + } + this.linker = linker; + } + + /** + * Create a fragment instance with given host and scope. + * + * @param {Vue} host + * @param {Object} scope + * @param {Fragment} parentFrag + */ + + FragmentFactory.prototype.create = function (host, scope, parentFrag) { + var frag = cloneNode(this.template); + return new Fragment(this.linker, this.vm, frag, host, scope, parentFrag); + }; + + var ON = 700; + var MODEL = 800; + var BIND = 850; + var TRANSITION = 1100; + var EL = 1500; + var COMPONENT = 1500; + var PARTIAL = 1750; + var IF = 2100; + var FOR = 2200; + var SLOT = 2300; + + var uid$3 = 0; + + var vFor = { + + priority: FOR, + terminal: true, + + params: ['track-by', 'stagger', 'enter-stagger', 'leave-stagger'], + + bind: function bind() { + // support "item in/of items" syntax + var inMatch = this.expression.match(/(.*) (?:in|of) (.*)/); + if (inMatch) { + var itMatch = inMatch[1].match(/\((.*),(.*)\)/); + if (itMatch) { + this.iterator = itMatch[1].trim(); + this.alias = itMatch[2].trim(); + } else { + this.alias = inMatch[1].trim(); + } + this.expression = inMatch[2]; + } + + if (!this.alias) { + 'development' !== 'production' && warn('Invalid v-for expression "' + this.descriptor.raw + '": ' + 'alias is required.', this.vm); + return; + } + + // uid as a cache identifier + this.id = '__v-for__' + ++uid$3; + + // check if this is an option list, + // so that we know if we need to update the <select>'s + // v-model when the option list has changed. + // because v-model has a lower priority than v-for, + // the v-model is not bound here yet, so we have to + // retrive it in the actual updateModel() function. + var tag = this.el.tagName; + this.isOption = (tag === 'OPTION' || tag === 'OPTGROUP') && this.el.parentNode.tagName === 'SELECT'; + + // setup anchor nodes + this.start = createAnchor('v-for-start'); + this.end = createAnchor('v-for-end'); + replace(this.el, this.end); + before(this.start, this.end); + + // cache + this.cache = Object.create(null); + + // fragment factory + this.factory = new FragmentFactory(this.vm, this.el); + }, + + update: function update(data) { + this.diff(data); + this.updateRef(); + this.updateModel(); + }, + + /** + * Diff, based on new data and old data, determine the + * minimum amount of DOM manipulations needed to make the + * DOM reflect the new data Array. + * + * The algorithm diffs the new data Array by storing a + * hidden reference to an owner vm instance on previously + * seen data. This allows us to achieve O(n) which is + * better than a levenshtein distance based algorithm, + * which is O(m * n). + * + * @param {Array} data + */ + + diff: function diff(data) { + // check if the Array was converted from an Object + var item = data[0]; + var convertedFromObject = this.fromObject = isObject(item) && hasOwn(item, '$key') && hasOwn(item, '$value'); + + var trackByKey = this.params.trackBy; + var oldFrags = this.frags; + var frags = this.frags = new Array(data.length); + var alias = this.alias; + var iterator = this.iterator; + var start = this.start; + var end = this.end; + var inDocument = inDoc(start); + var init = !oldFrags; + var i, l, frag, key, value, primitive; + + // First pass, go through the new Array and fill up + // the new frags array. If a piece of data has a cached + // instance for it, we reuse it. Otherwise build a new + // instance. + for (i = 0, l = data.length; i < l; i++) { + item = data[i]; + key = convertedFromObject ? item.$key : null; + value = convertedFromObject ? item.$value : item; + primitive = !isObject(value); + frag = !init && this.getCachedFrag(value, i, key); + if (frag) { + // reusable fragment + frag.reused = true; + // update $index + frag.scope.$index = i; + // update $key + if (key) { + frag.scope.$key = key; + } + // update iterator + if (iterator) { + frag.scope[iterator] = key !== null ? key : i; + } + // update data for track-by, object repeat & + // primitive values. + if (trackByKey || convertedFromObject || primitive) { + withoutConversion(function () { + frag.scope[alias] = value; + }); + } + } else { + // new isntance + frag = this.create(value, alias, i, key); + frag.fresh = !init; + } + frags[i] = frag; + if (init) { + frag.before(end); + } + } + + // we're done for the initial render. + if (init) { + return; + } + + // Second pass, go through the old fragments and + // destroy those who are not reused (and remove them + // from cache) + var removalIndex = 0; + var totalRemoved = oldFrags.length - frags.length; + // when removing a large number of fragments, watcher removal + // turns out to be a perf bottleneck, so we batch the watcher + // removals into a single filter call! + this.vm._vForRemoving = true; + for (i = 0, l = oldFrags.length; i < l; i++) { + frag = oldFrags[i]; + if (!frag.reused) { + this.deleteCachedFrag(frag); + this.remove(frag, removalIndex++, totalRemoved, inDocument); + } + } + this.vm._vForRemoving = false; + if (removalIndex) { + this.vm._watchers = this.vm._watchers.filter(function (w) { + return w.active; + }); + } + + // Final pass, move/insert new fragments into the + // right place. + var targetPrev, prevEl, currentPrev; + var insertionIndex = 0; + for (i = 0, l = frags.length; i < l; i++) { + frag = frags[i]; + // this is the frag that we should be after + targetPrev = frags[i - 1]; + prevEl = targetPrev ? targetPrev.staggerCb ? targetPrev.staggerAnchor : targetPrev.end || targetPrev.node : start; + if (frag.reused && !frag.staggerCb) { + currentPrev = findPrevFrag(frag, start, this.id); + if (currentPrev !== targetPrev && (!currentPrev || + // optimization for moving a single item. + // thanks to suggestions by @livoras in #1807 + findPrevFrag(currentPrev, start, this.id) !== targetPrev)) { + this.move(frag, prevEl); + } + } else { + // new instance, or still in stagger. + // insert with updated stagger index. + this.insert(frag, insertionIndex++, prevEl, inDocument); + } + frag.reused = frag.fresh = false; + } + }, + + /** + * Create a new fragment instance. + * + * @param {*} value + * @param {String} alias + * @param {Number} index + * @param {String} [key] + * @return {Fragment} + */ + + create: function create(value, alias, index, key) { + var host = this._host; + // create iteration scope + var parentScope = this._scope || this.vm; + var scope = Object.create(parentScope); + // ref holder for the scope + scope.$refs = Object.create(parentScope.$refs); + scope.$els = Object.create(parentScope.$els); + // make sure point $parent to parent scope + scope.$parent = parentScope; + // for two-way binding on alias + scope.$forContext = this; + // define scope properties + // important: define the scope alias without forced conversion + // so that frozen data structures remain non-reactive. + withoutConversion(function () { + defineReactive(scope, alias, value); + }); + defineReactive(scope, '$index', index); + if (key) { + defineReactive(scope, '$key', key); + } else if (scope.$key) { + // avoid accidental fallback + def(scope, '$key', null); + } + if (this.iterator) { + defineReactive(scope, this.iterator, key !== null ? key : index); + } + var frag = this.factory.create(host, scope, this._frag); + frag.forId = this.id; + this.cacheFrag(value, frag, index, key); + return frag; + }, + + /** + * Update the v-ref on owner vm. + */ + + updateRef: function updateRef() { + var ref = this.descriptor.ref; + if (!ref) return; + var hash = (this._scope || this.vm).$refs; + var refs; + if (!this.fromObject) { + refs = this.frags.map(findVmFromFrag); + } else { + refs = {}; + this.frags.forEach(function (frag) { + refs[frag.scope.$key] = findVmFromFrag(frag); + }); + } + hash[ref] = refs; + }, + + /** + * For option lists, update the containing v-model on + * parent <select>. + */ + + updateModel: function updateModel() { + if (this.isOption) { + var parent = this.start.parentNode; + var model = parent && parent.__v_model; + if (model) { + model.forceUpdate(); + } + } + }, + + /** + * Insert a fragment. Handles staggering. + * + * @param {Fragment} frag + * @param {Number} index + * @param {Node} prevEl + * @param {Boolean} inDocument + */ + + insert: function insert(frag, index, prevEl, inDocument) { + if (frag.staggerCb) { + frag.staggerCb.cancel(); + frag.staggerCb = null; + } + var staggerAmount = this.getStagger(frag, index, null, 'enter'); + if (inDocument && staggerAmount) { + // create an anchor and insert it synchronously, + // so that we can resolve the correct order without + // worrying about some elements not inserted yet + var anchor = frag.staggerAnchor; + if (!anchor) { + anchor = frag.staggerAnchor = createAnchor('stagger-anchor'); + anchor.__v_frag = frag; + } + after(anchor, prevEl); + var op = frag.staggerCb = cancellable(function () { + frag.staggerCb = null; + frag.before(anchor); + remove(anchor); + }); + setTimeout(op, staggerAmount); + } else { + var target = prevEl.nextSibling; + /* istanbul ignore if */ + if (!target) { + // reset end anchor position in case the position was messed up + // by an external drag-n-drop library. + after(this.end, prevEl); + target = this.end; + } + frag.before(target); + } + }, + + /** + * Remove a fragment. Handles staggering. + * + * @param {Fragment} frag + * @param {Number} index + * @param {Number} total + * @param {Boolean} inDocument + */ + + remove: function remove(frag, index, total, inDocument) { + if (frag.staggerCb) { + frag.staggerCb.cancel(); + frag.staggerCb = null; + // it's not possible for the same frag to be removed + // twice, so if we have a pending stagger callback, + // it means this frag is queued for enter but removed + // before its transition started. Since it is already + // destroyed, we can just leave it in detached state. + return; + } + var staggerAmount = this.getStagger(frag, index, total, 'leave'); + if (inDocument && staggerAmount) { + var op = frag.staggerCb = cancellable(function () { + frag.staggerCb = null; + frag.remove(); + }); + setTimeout(op, staggerAmount); + } else { + frag.remove(); + } + }, + + /** + * Move a fragment to a new position. + * Force no transition. + * + * @param {Fragment} frag + * @param {Node} prevEl + */ + + move: function move(frag, prevEl) { + // fix a common issue with Sortable: + // if prevEl doesn't have nextSibling, this means it's + // been dragged after the end anchor. Just re-position + // the end anchor to the end of the container. + /* istanbul ignore if */ + if (!prevEl.nextSibling) { + this.end.parentNode.appendChild(this.end); + } + frag.before(prevEl.nextSibling, false); + }, + + /** + * Cache a fragment using track-by or the object key. + * + * @param {*} value + * @param {Fragment} frag + * @param {Number} index + * @param {String} [key] + */ + + cacheFrag: function cacheFrag(value, frag, index, key) { + var trackByKey = this.params.trackBy; + var cache = this.cache; + var primitive = !isObject(value); + var id; + if (key || trackByKey || primitive) { + id = getTrackByKey(index, key, value, trackByKey); + if (!cache[id]) { + cache[id] = frag; + } else if (trackByKey !== '$index') { + 'development' !== 'production' && this.warnDuplicate(value); + } + } else { + id = this.id; + if (hasOwn(value, id)) { + if (value[id] === null) { + value[id] = frag; + } else { + 'development' !== 'production' && this.warnDuplicate(value); + } + } else if (Object.isExtensible(value)) { + def(value, id, frag); + } else if ('development' !== 'production') { + warn('Frozen v-for objects cannot be automatically tracked, make sure to ' + 'provide a track-by key.'); + } + } + frag.raw = value; + }, + + /** + * Get a cached fragment from the value/index/key + * + * @param {*} value + * @param {Number} index + * @param {String} key + * @return {Fragment} + */ + + getCachedFrag: function getCachedFrag(value, index, key) { + var trackByKey = this.params.trackBy; + var primitive = !isObject(value); + var frag; + if (key || trackByKey || primitive) { + var id = getTrackByKey(index, key, value, trackByKey); + frag = this.cache[id]; + } else { + frag = value[this.id]; + } + if (frag && (frag.reused || frag.fresh)) { + 'development' !== 'production' && this.warnDuplicate(value); + } + return frag; + }, + + /** + * Delete a fragment from cache. + * + * @param {Fragment} frag + */ + + deleteCachedFrag: function deleteCachedFrag(frag) { + var value = frag.raw; + var trackByKey = this.params.trackBy; + var scope = frag.scope; + var index = scope.$index; + // fix #948: avoid accidentally fall through to + // a parent repeater which happens to have $key. + var key = hasOwn(scope, '$key') && scope.$key; + var primitive = !isObject(value); + if (trackByKey || key || primitive) { + var id = getTrackByKey(index, key, value, trackByKey); + this.cache[id] = null; + } else { + value[this.id] = null; + frag.raw = null; + } + }, + + /** + * Get the stagger amount for an insertion/removal. + * + * @param {Fragment} frag + * @param {Number} index + * @param {Number} total + * @param {String} type + */ + + getStagger: function getStagger(frag, index, total, type) { + type = type + 'Stagger'; + var trans = frag.node.__v_trans; + var hooks = trans && trans.hooks; + var hook = hooks && (hooks[type] || hooks.stagger); + return hook ? hook.call(frag, index, total) : index * parseInt(this.params[type] || this.params.stagger, 10); + }, + + /** + * Pre-process the value before piping it through the + * filters. This is passed to and called by the watcher. + */ + + _preProcess: function _preProcess(value) { + // regardless of type, store the un-filtered raw value. + this.rawValue = value; + return value; + }, + + /** + * Post-process the value after it has been piped through + * the filters. This is passed to and called by the watcher. + * + * It is necessary for this to be called during the + * watcher's dependency collection phase because we want + * the v-for to update when the source Object is mutated. + */ + + _postProcess: function _postProcess(value) { + if (isArray(value)) { + return value; + } else if (isPlainObject(value)) { + // convert plain object to array. + var keys = Object.keys(value); + var i = keys.length; + var res = new Array(i); + var key; + while (i--) { + key = keys[i]; + res[i] = { + $key: key, + $value: value[key] + }; + } + return res; + } else { + if (typeof value === 'number' && !isNaN(value)) { + value = range(value); + } + return value || []; + } + }, + + unbind: function unbind() { + if (this.descriptor.ref) { + (this._scope || this.vm).$refs[this.descriptor.ref] = null; + } + if (this.frags) { + var i = this.frags.length; + var frag; + while (i--) { + frag = this.frags[i]; + this.deleteCachedFrag(frag); + frag.destroy(); + } + } + } + }; + + /** + * Helper to find the previous element that is a fragment + * anchor. This is necessary because a destroyed frag's + * element could still be lingering in the DOM before its + * leaving transition finishes, but its inserted flag + * should have been set to false so we can skip them. + * + * If this is a block repeat, we want to make sure we only + * return frag that is bound to this v-for. (see #929) + * + * @param {Fragment} frag + * @param {Comment|Text} anchor + * @param {String} id + * @return {Fragment} + */ + + function findPrevFrag(frag, anchor, id) { + var el = frag.node.previousSibling; + /* istanbul ignore if */ + if (!el) return; + frag = el.__v_frag; + while ((!frag || frag.forId !== id || !frag.inserted) && el !== anchor) { + el = el.previousSibling; + /* istanbul ignore if */ + if (!el) return; + frag = el.__v_frag; + } + return frag; + } + + /** + * Find a vm from a fragment. + * + * @param {Fragment} frag + * @return {Vue|undefined} + */ + + function findVmFromFrag(frag) { + var node = frag.node; + // handle multi-node frag + if (frag.end) { + while (!node.__vue__ && node !== frag.end && node.nextSibling) { + node = node.nextSibling; + } + } + return node.__vue__; + } + + /** + * Create a range array from given number. + * + * @param {Number} n + * @return {Array} + */ + + function range(n) { + var i = -1; + var ret = new Array(Math.floor(n)); + while (++i < n) { + ret[i] = i; + } + return ret; + } + + /** + * Get the track by key for an item. + * + * @param {Number} index + * @param {String} key + * @param {*} value + * @param {String} [trackByKey] + */ + + function getTrackByKey(index, key, value, trackByKey) { + return trackByKey ? trackByKey === '$index' ? index : trackByKey.charAt(0).match(/\w/) ? getPath(value, trackByKey) : value[trackByKey] : key || value; + } + + if ('development' !== 'production') { + vFor.warnDuplicate = function (value) { + warn('Duplicate value found in v-for="' + this.descriptor.raw + '": ' + JSON.stringify(value) + '. Use track-by="$index" if ' + 'you are expecting duplicate values.', this.vm); + }; + } + + var vIf = { + + priority: IF, + terminal: true, + + bind: function bind() { + var el = this.el; + if (!el.__vue__) { + // check else block + var next = el.nextElementSibling; + if (next && getAttr(next, 'v-else') !== null) { + remove(next); + this.elseEl = next; + } + // check main block + this.anchor = createAnchor('v-if'); + replace(el, this.anchor); + } else { + 'development' !== 'production' && warn('v-if="' + this.expression + '" cannot be ' + 'used on an instance root element.', this.vm); + this.invalid = true; + } + }, + + update: function update(value) { + if (this.invalid) return; + if (value) { + if (!this.frag) { + this.insert(); + } + } else { + this.remove(); + } + }, + + insert: function insert() { + if (this.elseFrag) { + this.elseFrag.remove(); + this.elseFrag = null; + } + // lazy init factory + if (!this.factory) { + this.factory = new FragmentFactory(this.vm, this.el); + } + this.frag = this.factory.create(this._host, this._scope, this._frag); + this.frag.before(this.anchor); + }, + + remove: function remove() { + if (this.frag) { + this.frag.remove(); + this.frag = null; + } + if (this.elseEl && !this.elseFrag) { + if (!this.elseFactory) { + this.elseFactory = new FragmentFactory(this.elseEl._context || this.vm, this.elseEl); + } + this.elseFrag = this.elseFactory.create(this._host, this._scope, this._frag); + this.elseFrag.before(this.anchor); + } + }, + + unbind: function unbind() { + if (this.frag) { + this.frag.destroy(); + } + if (this.elseFrag) { + this.elseFrag.destroy(); + } + } + }; + + var show = { + + bind: function bind() { + // check else block + var next = this.el.nextElementSibling; + if (next && getAttr(next, 'v-else') !== null) { + this.elseEl = next; + } + }, + + update: function update(value) { + this.apply(this.el, value); + if (this.elseEl) { + this.apply(this.elseEl, !value); + } + }, + + apply: function apply(el, value) { + if (inDoc(el)) { + applyTransition(el, value ? 1 : -1, toggle, this.vm); + } else { + toggle(); + } + function toggle() { + el.style.display = value ? '' : 'none'; + } + } + }; + + var text$2 = { + + bind: function bind() { + var self = this; + var el = this.el; + var isRange = el.type === 'range'; + var lazy = this.params.lazy; + var number = this.params.number; + var debounce = this.params.debounce; + + // handle composition events. + // http://blog.evanyou.me/2014/01/03/composition-event/ + // skip this for Android because it handles composition + // events quite differently. Android doesn't trigger + // composition events for language input methods e.g. + // Chinese, but instead triggers them for spelling + // suggestions... (see Discussion/#162) + var composing = false; + if (!isAndroid && !isRange) { + this.on('compositionstart', function () { + composing = true; + }); + this.on('compositionend', function () { + composing = false; + // in IE11 the "compositionend" event fires AFTER + // the "input" event, so the input handler is blocked + // at the end... have to call it here. + // + // #1327: in lazy mode this is unecessary. + if (!lazy) { + self.listener(); + } + }); + } + + // prevent messing with the input when user is typing, + // and force update on blur. + this.focused = false; + if (!isRange && !lazy) { + this.on('focus', function () { + self.focused = true; + }); + this.on('blur', function () { + self.focused = false; + // do not sync value after fragment removal (#2017) + if (!self._frag || self._frag.inserted) { + self.rawListener(); + } + }); + } + + // Now attach the main listener + this.listener = this.rawListener = function () { + if (composing || !self._bound) { + return; + } + var val = number || isRange ? toNumber(el.value) : el.value; + self.set(val); + // force update on next tick to avoid lock & same value + // also only update when user is not typing + nextTick(function () { + if (self._bound && !self.focused) { + self.update(self._watcher.value); + } + }); + }; + + // apply debounce + if (debounce) { + this.listener = _debounce(this.listener, debounce); + } + + // Support jQuery events, since jQuery.trigger() doesn't + // trigger native events in some cases and some plugins + // rely on $.trigger() + // + // We want to make sure if a listener is attached using + // jQuery, it is also removed with jQuery, that's why + // we do the check for each directive instance and + // store that check result on itself. This also allows + // easier test coverage control by unsetting the global + // jQuery variable in tests. + this.hasjQuery = typeof jQuery === 'function'; + if (this.hasjQuery) { + var method = jQuery.fn.on ? 'on' : 'bind'; + jQuery(el)[method]('change', this.rawListener); + if (!lazy) { + jQuery(el)[method]('input', this.listener); + } + } else { + this.on('change', this.rawListener); + if (!lazy) { + this.on('input', this.listener); + } + } + + // IE9 doesn't fire input event on backspace/del/cut + if (!lazy && isIE9) { + this.on('cut', function () { + nextTick(self.listener); + }); + this.on('keyup', function (e) { + if (e.keyCode === 46 || e.keyCode === 8) { + self.listener(); + } + }); + } + + // set initial value if present + if (el.hasAttribute('value') || el.tagName === 'TEXTAREA' && el.value.trim()) { + this.afterBind = this.listener; + } + }, + + update: function update(value) { + // #3029 only update when the value changes. This prevent + // browsers from overwriting values like selectionStart + value = _toString(value); + if (value !== this.el.value) this.el.value = value; + }, + + unbind: function unbind() { + var el = this.el; + if (this.hasjQuery) { + var method = jQuery.fn.off ? 'off' : 'unbind'; + jQuery(el)[method]('change', this.listener); + jQuery(el)[method]('input', this.listener); + } + } + }; + + var radio = { + + bind: function bind() { + var self = this; + var el = this.el; + + this.getValue = function () { + // value overwrite via v-bind:value + if (el.hasOwnProperty('_value')) { + return el._value; + } + var val = el.value; + if (self.params.number) { + val = toNumber(val); + } + return val; + }; + + this.listener = function () { + self.set(self.getValue()); + }; + this.on('change', this.listener); + + if (el.hasAttribute('checked')) { + this.afterBind = this.listener; + } + }, + + update: function update(value) { + this.el.checked = looseEqual(value, this.getValue()); + } + }; + + var select = { + + bind: function bind() { + var _this = this; + + var self = this; + var el = this.el; + + // method to force update DOM using latest value. + this.forceUpdate = function () { + if (self._watcher) { + self.update(self._watcher.get()); + } + }; + + // check if this is a multiple select + var multiple = this.multiple = el.hasAttribute('multiple'); + + // attach listener + this.listener = function () { + var value = getValue(el, multiple); + value = self.params.number ? isArray(value) ? value.map(toNumber) : toNumber(value) : value; + self.set(value); + }; + this.on('change', this.listener); + + // if has initial value, set afterBind + var initValue = getValue(el, multiple, true); + if (multiple && initValue.length || !multiple && initValue !== null) { + this.afterBind = this.listener; + } + + // All major browsers except Firefox resets + // selectedIndex with value -1 to 0 when the element + // is appended to a new parent, therefore we have to + // force a DOM update whenever that happens... + this.vm.$on('hook:attached', function () { + nextTick(_this.forceUpdate); + }); + if (!inDoc(el)) { + nextTick(this.forceUpdate); + } + }, + + update: function update(value) { + var el = this.el; + el.selectedIndex = -1; + var multi = this.multiple && isArray(value); + var options = el.options; + var i = options.length; + var op, val; + while (i--) { + op = options[i]; + val = op.hasOwnProperty('_value') ? op._value : op.value; + /* eslint-disable eqeqeq */ + op.selected = multi ? indexOf$1(value, val) > -1 : looseEqual(value, val); + /* eslint-enable eqeqeq */ + } + }, + + unbind: function unbind() { + /* istanbul ignore next */ + this.vm.$off('hook:attached', this.forceUpdate); + } + }; + + /** + * Get select value + * + * @param {SelectElement} el + * @param {Boolean} multi + * @param {Boolean} init + * @return {Array|*} + */ + + function getValue(el, multi, init) { + var res = multi ? [] : null; + var op, val, selected; + for (var i = 0, l = el.options.length; i < l; i++) { + op = el.options[i]; + selected = init ? op.hasAttribute('selected') : op.selected; + if (selected) { + val = op.hasOwnProperty('_value') ? op._value : op.value; + if (multi) { + res.push(val); + } else { + return val; + } + } + } + return res; + } + + /** + * Native Array.indexOf uses strict equal, but in this + * case we need to match string/numbers with custom equal. + * + * @param {Array} arr + * @param {*} val + */ + + function indexOf$1(arr, val) { + var i = arr.length; + while (i--) { + if (looseEqual(arr[i], val)) { + return i; + } + } + return -1; + } + + var checkbox = { + + bind: function bind() { + var self = this; + var el = this.el; + + this.getValue = function () { + return el.hasOwnProperty('_value') ? el._value : self.params.number ? toNumber(el.value) : el.value; + }; + + function getBooleanValue() { + var val = el.checked; + if (val && el.hasOwnProperty('_trueValue')) { + return el._trueValue; + } + if (!val && el.hasOwnProperty('_falseValue')) { + return el._falseValue; + } + return val; + } + + this.listener = function () { + var model = self._watcher.value; + if (isArray(model)) { + var val = self.getValue(); + if (el.checked) { + if (indexOf(model, val) < 0) { + model.push(val); + } + } else { + model.$remove(val); + } + } else { + self.set(getBooleanValue()); + } + }; + + this.on('change', this.listener); + if (el.hasAttribute('checked')) { + this.afterBind = this.listener; + } + }, + + update: function update(value) { + var el = this.el; + if (isArray(value)) { + el.checked = indexOf(value, this.getValue()) > -1; + } else { + if (el.hasOwnProperty('_trueValue')) { + el.checked = looseEqual(value, el._trueValue); + } else { + el.checked = !!value; + } + } + } + }; + + var handlers = { + text: text$2, + radio: radio, + select: select, + checkbox: checkbox + }; + + var model = { + + priority: MODEL, + twoWay: true, + handlers: handlers, + params: ['lazy', 'number', 'debounce'], + + /** + * Possible elements: + * <select> + * <textarea> + * <input type="*"> + * - text + * - checkbox + * - radio + * - number + */ + + bind: function bind() { + // friendly warning... + this.checkFilters(); + if (this.hasRead && !this.hasWrite) { + 'development' !== 'production' && warn('It seems you are using a read-only filter with ' + 'v-model="' + this.descriptor.raw + '". ' + 'You might want to use a two-way filter to ensure correct behavior.', this.vm); + } + var el = this.el; + var tag = el.tagName; + var handler; + if (tag === 'INPUT') { + handler = handlers[el.type] || handlers.text; + } else if (tag === 'SELECT') { + handler = handlers.select; + } else if (tag === 'TEXTAREA') { + handler = handlers.text; + } else { + 'development' !== 'production' && warn('v-model does not support element type: ' + tag, this.vm); + return; + } + el.__v_model = this; + handler.bind.call(this); + this.update = handler.update; + this._unbind = handler.unbind; + }, + + /** + * Check read/write filter stats. + */ + + checkFilters: function checkFilters() { + var filters = this.filters; + if (!filters) return; + var i = filters.length; + while (i--) { + var filter = resolveAsset(this.vm.$options, 'filters', filters[i].name); + if (typeof filter === 'function' || filter.read) { + this.hasRead = true; + } + if (filter.write) { + this.hasWrite = true; + } + } + }, + + unbind: function unbind() { + this.el.__v_model = null; + this._unbind && this._unbind(); + } + }; + + // keyCode aliases + var keyCodes = { + esc: 27, + tab: 9, + enter: 13, + space: 32, + 'delete': [8, 46], + up: 38, + left: 37, + right: 39, + down: 40 + }; + + function keyFilter(handler, keys) { + var codes = keys.map(function (key) { + var charCode = key.charCodeAt(0); + if (charCode > 47 && charCode < 58) { + return parseInt(key, 10); + } + if (key.length === 1) { + charCode = key.toUpperCase().charCodeAt(0); + if (charCode > 64 && charCode < 91) { + return charCode; + } + } + return keyCodes[key]; + }); + codes = [].concat.apply([], codes); + return function keyHandler(e) { + if (codes.indexOf(e.keyCode) > -1) { + return handler.call(this, e); + } + }; + } + + function stopFilter(handler) { + return function stopHandler(e) { + e.stopPropagation(); + return handler.call(this, e); + }; + } + + function preventFilter(handler) { + return function preventHandler(e) { + e.preventDefault(); + return handler.call(this, e); + }; + } + + function selfFilter(handler) { + return function selfHandler(e) { + if (e.target === e.currentTarget) { + return handler.call(this, e); + } + }; + } + + var on$1 = { + + priority: ON, + acceptStatement: true, + keyCodes: keyCodes, + + bind: function bind() { + // deal with iframes + if (this.el.tagName === 'IFRAME' && this.arg !== 'load') { + var self = this; + this.iframeBind = function () { + on(self.el.contentWindow, self.arg, self.handler, self.modifiers.capture); + }; + this.on('load', this.iframeBind); + } + }, + + update: function update(handler) { + // stub a noop for v-on with no value, + // e.g. @mousedown.prevent + if (!this.descriptor.raw) { + handler = function () {}; + } + + if (typeof handler !== 'function') { + 'development' !== 'production' && warn('v-on:' + this.arg + '="' + this.expression + '" expects a function value, ' + 'got ' + handler, this.vm); + return; + } + + // apply modifiers + if (this.modifiers.stop) { + handler = stopFilter(handler); + } + if (this.modifiers.prevent) { + handler = preventFilter(handler); + } + if (this.modifiers.self) { + handler = selfFilter(handler); + } + // key filter + var keys = Object.keys(this.modifiers).filter(function (key) { + return key !== 'stop' && key !== 'prevent' && key !== 'self' && key !== 'capture'; + }); + if (keys.length) { + handler = keyFilter(handler, keys); + } + + this.reset(); + this.handler = handler; + + if (this.iframeBind) { + this.iframeBind(); + } else { + on(this.el, this.arg, this.handler, this.modifiers.capture); + } + }, + + reset: function reset() { + var el = this.iframeBind ? this.el.contentWindow : this.el; + if (this.handler) { + off(el, this.arg, this.handler); + } + }, + + unbind: function unbind() { + this.reset(); + } + }; + + var prefixes = ['-webkit-', '-moz-', '-ms-']; + var camelPrefixes = ['Webkit', 'Moz', 'ms']; + var importantRE = /!important;?$/; + var propCache = Object.create(null); + + var testEl = null; + + var style = { + + deep: true, + + update: function update(value) { + if (typeof value === 'string') { + this.el.style.cssText = value; + } else if (isArray(value)) { + this.handleObject(value.reduce(extend, {})); + } else { + this.handleObject(value || {}); + } + }, + + handleObject: function handleObject(value) { + // cache object styles so that only changed props + // are actually updated. + var cache = this.cache || (this.cache = {}); + var name, val; + for (name in cache) { + if (!(name in value)) { + this.handleSingle(name, null); + delete cache[name]; + } + } + for (name in value) { + val = value[name]; + if (val !== cache[name]) { + cache[name] = val; + this.handleSingle(name, val); + } + } + }, + + handleSingle: function handleSingle(prop, value) { + prop = normalize(prop); + if (!prop) return; // unsupported prop + // cast possible numbers/booleans into strings + if (value != null) value += ''; + if (value) { + var isImportant = importantRE.test(value) ? 'important' : ''; + if (isImportant) { + /* istanbul ignore if */ + if ('development' !== 'production') { + warn('It\'s probably a bad idea to use !important with inline rules. ' + 'This feature will be deprecated in a future version of Vue.'); + } + value = value.replace(importantRE, '').trim(); + this.el.style.setProperty(prop.kebab, value, isImportant); + } else { + this.el.style[prop.camel] = value; + } + } else { + this.el.style[prop.camel] = ''; + } + } + + }; + + /** + * Normalize a CSS property name. + * - cache result + * - auto prefix + * - camelCase -> dash-case + * + * @param {String} prop + * @return {String} + */ + + function normalize(prop) { + if (propCache[prop]) { + return propCache[prop]; + } + var res = prefix(prop); + propCache[prop] = propCache[res] = res; + return res; + } + + /** + * Auto detect the appropriate prefix for a CSS property. + * https://gist.github.com/paulirish/523692 + * + * @param {String} prop + * @return {String} + */ + + function prefix(prop) { + prop = hyphenate(prop); + var camel = camelize(prop); + var upper = camel.charAt(0).toUpperCase() + camel.slice(1); + if (!testEl) { + testEl = document.createElement('div'); + } + var i = prefixes.length; + var prefixed; + if (camel !== 'filter' && camel in testEl.style) { + return { + kebab: prop, + camel: camel + }; + } + while (i--) { + prefixed = camelPrefixes[i] + upper; + if (prefixed in testEl.style) { + return { + kebab: prefixes[i] + prop, + camel: prefixed + }; + } + } + } + + // xlink + var xlinkNS = 'http://www.w3.org/1999/xlink'; + var xlinkRE = /^xlink:/; + + // check for attributes that prohibit interpolations + var disallowedInterpAttrRE = /^v-|^:|^@|^(?:is|transition|transition-mode|debounce|track-by|stagger|enter-stagger|leave-stagger)$/; + // these attributes should also set their corresponding properties + // because they only affect the initial state of the element + var attrWithPropsRE = /^(?:value|checked|selected|muted)$/; + // these attributes expect enumrated values of "true" or "false" + // but are not boolean attributes + var enumeratedAttrRE = /^(?:draggable|contenteditable|spellcheck)$/; + + // these attributes should set a hidden property for + // binding v-model to object values + var modelProps = { + value: '_value', + 'true-value': '_trueValue', + 'false-value': '_falseValue' + }; + + var bind$1 = { + + priority: BIND, + + bind: function bind() { + var attr = this.arg; + var tag = this.el.tagName; + // should be deep watch on object mode + if (!attr) { + this.deep = true; + } + // handle interpolation bindings + var descriptor = this.descriptor; + var tokens = descriptor.interp; + if (tokens) { + // handle interpolations with one-time tokens + if (descriptor.hasOneTime) { + this.expression = tokensToExp(tokens, this._scope || this.vm); + } + + // only allow binding on native attributes + if (disallowedInterpAttrRE.test(attr) || attr === 'name' && (tag === 'PARTIAL' || tag === 'SLOT')) { + 'development' !== 'production' && warn(attr + '="' + descriptor.raw + '": ' + 'attribute interpolation is not allowed in Vue.js ' + 'directives and special attributes.', this.vm); + this.el.removeAttribute(attr); + this.invalid = true; + } + + /* istanbul ignore if */ + if ('development' !== 'production') { + var raw = attr + '="' + descriptor.raw + '": '; + // warn src + if (attr === 'src') { + warn(raw + 'interpolation in "src" attribute will cause ' + 'a 404 request. Use v-bind:src instead.', this.vm); + } + + // warn style + if (attr === 'style') { + warn(raw + 'interpolation in "style" attribute will cause ' + 'the attribute to be discarded in Internet Explorer. ' + 'Use v-bind:style instead.', this.vm); + } + } + } + }, + + update: function update(value) { + if (this.invalid) { + return; + } + var attr = this.arg; + if (this.arg) { + this.handleSingle(attr, value); + } else { + this.handleObject(value || {}); + } + }, + + // share object handler with v-bind:class + handleObject: style.handleObject, + + handleSingle: function handleSingle(attr, value) { + var el = this.el; + var interp = this.descriptor.interp; + if (this.modifiers.camel) { + attr = camelize(attr); + } + if (!interp && attrWithPropsRE.test(attr) && attr in el) { + var attrValue = attr === 'value' ? value == null // IE9 will set input.value to "null" for null... + ? '' : value : value; + + if (el[attr] !== attrValue) { + el[attr] = attrValue; + } + } + // set model props + var modelProp = modelProps[attr]; + if (!interp && modelProp) { + el[modelProp] = value; + // update v-model if present + var model = el.__v_model; + if (model) { + model.listener(); + } + } + // do not set value attribute for textarea + if (attr === 'value' && el.tagName === 'TEXTAREA') { + el.removeAttribute(attr); + return; + } + // update attribute + if (enumeratedAttrRE.test(attr)) { + el.setAttribute(attr, value ? 'true' : 'false'); + } else if (value != null && value !== false) { + if (attr === 'class') { + // handle edge case #1960: + // class interpolation should not overwrite Vue transition class + if (el.__v_trans) { + value += ' ' + el.__v_trans.id + '-transition'; + } + setClass(el, value); + } else if (xlinkRE.test(attr)) { + el.setAttributeNS(xlinkNS, attr, value === true ? '' : value); + } else { + el.setAttribute(attr, value === true ? '' : value); + } + } else { + el.removeAttribute(attr); + } + } + }; + + var el = { + + priority: EL, + + bind: function bind() { + /* istanbul ignore if */ + if (!this.arg) { + return; + } + var id = this.id = camelize(this.arg); + var refs = (this._scope || this.vm).$els; + if (hasOwn(refs, id)) { + refs[id] = this.el; + } else { + defineReactive(refs, id, this.el); + } + }, + + unbind: function unbind() { + var refs = (this._scope || this.vm).$els; + if (refs[this.id] === this.el) { + refs[this.id] = null; + } + } + }; + + var ref = { + bind: function bind() { + 'development' !== 'production' && warn('v-ref:' + this.arg + ' must be used on a child ' + 'component. Found on <' + this.el.tagName.toLowerCase() + '>.', this.vm); + } + }; + + var cloak = { + bind: function bind() { + var el = this.el; + this.vm.$once('pre-hook:compiled', function () { + el.removeAttribute('v-cloak'); + }); + } + }; + + // must export plain object + var directives = { + text: text$1, + html: html, + 'for': vFor, + 'if': vIf, + show: show, + model: model, + on: on$1, + bind: bind$1, + el: el, + ref: ref, + cloak: cloak + }; + + var vClass = { + + deep: true, + + update: function update(value) { + if (!value) { + this.cleanup(); + } else if (typeof value === 'string') { + this.setClass(value.trim().split(/\s+/)); + } else { + this.setClass(normalize$1(value)); + } + }, + + setClass: function setClass(value) { + this.cleanup(value); + for (var i = 0, l = value.length; i < l; i++) { + var val = value[i]; + if (val) { + apply(this.el, val, addClass); + } + } + this.prevKeys = value; + }, + + cleanup: function cleanup(value) { + var prevKeys = this.prevKeys; + if (!prevKeys) return; + var i = prevKeys.length; + while (i--) { + var key = prevKeys[i]; + if (!value || value.indexOf(key) < 0) { + apply(this.el, key, removeClass); + } + } + } + }; + + /** + * Normalize objects and arrays (potentially containing objects) + * into array of strings. + * + * @param {Object|Array<String|Object>} value + * @return {Array<String>} + */ + + function normalize$1(value) { + var res = []; + if (isArray(value)) { + for (var i = 0, l = value.length; i < l; i++) { + var _key = value[i]; + if (_key) { + if (typeof _key === 'string') { + res.push(_key); + } else { + for (var k in _key) { + if (_key[k]) res.push(k); + } + } + } + } + } else if (isObject(value)) { + for (var key in value) { + if (value[key]) res.push(key); + } + } + return res; + } + + /** + * Add or remove a class/classes on an element + * + * @param {Element} el + * @param {String} key The class name. This may or may not + * contain a space character, in such a + * case we'll deal with multiple class + * names at once. + * @param {Function} fn + */ + + function apply(el, key, fn) { + key = key.trim(); + if (key.indexOf(' ') === -1) { + fn(el, key); + return; + } + // The key contains one or more space characters. + // Since a class name doesn't accept such characters, we + // treat it as multiple classes. + var keys = key.split(/\s+/); + for (var i = 0, l = keys.length; i < l; i++) { + fn(el, keys[i]); + } + } + + var component = { + + priority: COMPONENT, + + params: ['keep-alive', 'transition-mode', 'inline-template'], + + /** + * Setup. Two possible usages: + * + * - static: + * <comp> or <div v-component="comp"> + * + * - dynamic: + * <component :is="view"> + */ + + bind: function bind() { + if (!this.el.__vue__) { + // keep-alive cache + this.keepAlive = this.params.keepAlive; + if (this.keepAlive) { + this.cache = {}; + } + // check inline-template + if (this.params.inlineTemplate) { + // extract inline template as a DocumentFragment + this.inlineTemplate = extractContent(this.el, true); + } + // component resolution related state + this.pendingComponentCb = this.Component = null; + // transition related state + this.pendingRemovals = 0; + this.pendingRemovalCb = null; + // create a ref anchor + this.anchor = createAnchor('v-component'); + replace(this.el, this.anchor); + // remove is attribute. + // this is removed during compilation, but because compilation is + // cached, when the component is used elsewhere this attribute + // will remain at link time. + this.el.removeAttribute('is'); + this.el.removeAttribute(':is'); + // remove ref, same as above + if (this.descriptor.ref) { + this.el.removeAttribute('v-ref:' + hyphenate(this.descriptor.ref)); + } + // if static, build right now. + if (this.literal) { + this.setComponent(this.expression); + } + } else { + 'development' !== 'production' && warn('cannot mount component "' + this.expression + '" ' + 'on already mounted element: ' + this.el); + } + }, + + /** + * Public update, called by the watcher in the dynamic + * literal scenario, e.g. <component :is="view"> + */ + + update: function update(value) { + if (!this.literal) { + this.setComponent(value); + } + }, + + /** + * Switch dynamic components. May resolve the component + * asynchronously, and perform transition based on + * specified transition mode. Accepts a few additional + * arguments specifically for vue-router. + * + * The callback is called when the full transition is + * finished. + * + * @param {String} value + * @param {Function} [cb] + */ + + setComponent: function setComponent(value, cb) { + this.invalidatePending(); + if (!value) { + // just remove current + this.unbuild(true); + this.remove(this.childVM, cb); + this.childVM = null; + } else { + var self = this; + this.resolveComponent(value, function () { + self.mountComponent(cb); + }); + } + }, + + /** + * Resolve the component constructor to use when creating + * the child vm. + * + * @param {String|Function} value + * @param {Function} cb + */ + + resolveComponent: function resolveComponent(value, cb) { + var self = this; + this.pendingComponentCb = cancellable(function (Component) { + self.ComponentName = Component.options.name || (typeof value === 'string' ? value : null); + self.Component = Component; + cb(); + }); + this.vm._resolveComponent(value, this.pendingComponentCb); + }, + + /** + * Create a new instance using the current constructor and + * replace the existing instance. This method doesn't care + * whether the new component and the old one are actually + * the same. + * + * @param {Function} [cb] + */ + + mountComponent: function mountComponent(cb) { + // actual mount + this.unbuild(true); + var self = this; + var activateHooks = this.Component.options.activate; + var cached = this.getCached(); + var newComponent = this.build(); + if (activateHooks && !cached) { + this.waitingFor = newComponent; + callActivateHooks(activateHooks, newComponent, function () { + if (self.waitingFor !== newComponent) { + return; + } + self.waitingFor = null; + self.transition(newComponent, cb); + }); + } else { + // update ref for kept-alive component + if (cached) { + newComponent._updateRef(); + } + this.transition(newComponent, cb); + } + }, + + /** + * When the component changes or unbinds before an async + * constructor is resolved, we need to invalidate its + * pending callback. + */ + + invalidatePending: function invalidatePending() { + if (this.pendingComponentCb) { + this.pendingComponentCb.cancel(); + this.pendingComponentCb = null; + } + }, + + /** + * Instantiate/insert a new child vm. + * If keep alive and has cached instance, insert that + * instance; otherwise build a new one and cache it. + * + * @param {Object} [extraOptions] + * @return {Vue} - the created instance + */ + + build: function build(extraOptions) { + var cached = this.getCached(); + if (cached) { + return cached; + } + if (this.Component) { + // default options + var options = { + name: this.ComponentName, + el: cloneNode(this.el), + template: this.inlineTemplate, + // make sure to add the child with correct parent + // if this is a transcluded component, its parent + // should be the transclusion host. + parent: this._host || this.vm, + // if no inline-template, then the compiled + // linker can be cached for better performance. + _linkerCachable: !this.inlineTemplate, + _ref: this.descriptor.ref, + _asComponent: true, + _isRouterView: this._isRouterView, + // if this is a transcluded component, context + // will be the common parent vm of this instance + // and its host. + _context: this.vm, + // if this is inside an inline v-for, the scope + // will be the intermediate scope created for this + // repeat fragment. this is used for linking props + // and container directives. + _scope: this._scope, + // pass in the owner fragment of this component. + // this is necessary so that the fragment can keep + // track of its contained components in order to + // call attach/detach hooks for them. + _frag: this._frag + }; + // extra options + // in 1.0.0 this is used by vue-router only + /* istanbul ignore if */ + if (extraOptions) { + extend(options, extraOptions); + } + var child = new this.Component(options); + if (this.keepAlive) { + this.cache[this.Component.cid] = child; + } + /* istanbul ignore if */ + if ('development' !== 'production' && this.el.hasAttribute('transition') && child._isFragment) { + warn('Transitions will not work on a fragment instance. ' + 'Template: ' + child.$options.template, child); + } + return child; + } + }, + + /** + * Try to get a cached instance of the current component. + * + * @return {Vue|undefined} + */ + + getCached: function getCached() { + return this.keepAlive && this.cache[this.Component.cid]; + }, + + /** + * Teardown the current child, but defers cleanup so + * that we can separate the destroy and removal steps. + * + * @param {Boolean} defer + */ + + unbuild: function unbuild(defer) { + if (this.waitingFor) { + if (!this.keepAlive) { + this.waitingFor.$destroy(); + } + this.waitingFor = null; + } + var child = this.childVM; + if (!child || this.keepAlive) { + if (child) { + // remove ref + child._inactive = true; + child._updateRef(true); + } + return; + } + // the sole purpose of `deferCleanup` is so that we can + // "deactivate" the vm right now and perform DOM removal + // later. + child.$destroy(false, defer); + }, + + /** + * Remove current destroyed child and manually do + * the cleanup after removal. + * + * @param {Function} cb + */ + + remove: function remove(child, cb) { + var keepAlive = this.keepAlive; + if (child) { + // we may have a component switch when a previous + // component is still being transitioned out. + // we want to trigger only one lastest insertion cb + // when the existing transition finishes. (#1119) + this.pendingRemovals++; + this.pendingRemovalCb = cb; + var self = this; + child.$remove(function () { + self.pendingRemovals--; + if (!keepAlive) child._cleanup(); + if (!self.pendingRemovals && self.pendingRemovalCb) { + self.pendingRemovalCb(); + self.pendingRemovalCb = null; + } + }); + } else if (cb) { + cb(); + } + }, + + /** + * Actually swap the components, depending on the + * transition mode. Defaults to simultaneous. + * + * @param {Vue} target + * @param {Function} [cb] + */ + + transition: function transition(target, cb) { + var self = this; + var current = this.childVM; + // for devtool inspection + if (current) current._inactive = true; + target._inactive = false; + this.childVM = target; + switch (self.params.transitionMode) { + case 'in-out': + target.$before(self.anchor, function () { + self.remove(current, cb); + }); + break; + case 'out-in': + self.remove(current, function () { + target.$before(self.anchor, cb); + }); + break; + default: + self.remove(current); + target.$before(self.anchor, cb); + } + }, + + /** + * Unbind. + */ + + unbind: function unbind() { + this.invalidatePending(); + // Do not defer cleanup when unbinding + this.unbuild(); + // destroy all keep-alive cached instances + if (this.cache) { + for (var key in this.cache) { + this.cache[key].$destroy(); + } + this.cache = null; + } + } + }; + + /** + * Call activate hooks in order (asynchronous) + * + * @param {Array} hooks + * @param {Vue} vm + * @param {Function} cb + */ + + function callActivateHooks(hooks, vm, cb) { + var total = hooks.length; + var called = 0; + hooks[0].call(vm, next); + function next() { + if (++called >= total) { + cb(); + } else { + hooks[called].call(vm, next); + } + } + } + + var propBindingModes = config._propBindingModes; + var empty = {}; + + // regexes + var identRE$1 = /^[$_a-zA-Z]+[\w$]*$/; + var settablePathRE = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*|\[[^\[\]]+\])*$/; + + /** + * Compile props on a root element and return + * a props link function. + * + * @param {Element|DocumentFragment} el + * @param {Array} propOptions + * @param {Vue} vm + * @return {Function} propsLinkFn + */ + + function compileProps(el, propOptions, vm) { + var props = []; + var names = Object.keys(propOptions); + var i = names.length; + var options, name, attr, value, path, parsed, prop; + while (i--) { + name = names[i]; + options = propOptions[name] || empty; + + if ('development' !== 'production' && name === '$data') { + warn('Do not use $data as prop.', vm); + continue; + } + + // props could contain dashes, which will be + // interpreted as minus calculations by the parser + // so we need to camelize the path here + path = camelize(name); + if (!identRE$1.test(path)) { + 'development' !== 'production' && warn('Invalid prop key: "' + name + '". Prop keys ' + 'must be valid identifiers.', vm); + continue; + } + + prop = { + name: name, + path: path, + options: options, + mode: propBindingModes.ONE_WAY, + raw: null + }; + + attr = hyphenate(name); + // first check dynamic version + if ((value = getBindAttr(el, attr)) === null) { + if ((value = getBindAttr(el, attr + '.sync')) !== null) { + prop.mode = propBindingModes.TWO_WAY; + } else if ((value = getBindAttr(el, attr + '.once')) !== null) { + prop.mode = propBindingModes.ONE_TIME; + } + } + if (value !== null) { + // has dynamic binding! + prop.raw = value; + parsed = parseDirective(value); + value = parsed.expression; + prop.filters = parsed.filters; + // check binding type + if (isLiteral(value) && !parsed.filters) { + // for expressions containing literal numbers and + // booleans, there's no need to setup a prop binding, + // so we can optimize them as a one-time set. + prop.optimizedLiteral = true; + } else { + prop.dynamic = true; + // check non-settable path for two-way bindings + if ('development' !== 'production' && prop.mode === propBindingModes.TWO_WAY && !settablePathRE.test(value)) { + prop.mode = propBindingModes.ONE_WAY; + warn('Cannot bind two-way prop with non-settable ' + 'parent path: ' + value, vm); + } + } + prop.parentPath = value; + + // warn required two-way + if ('development' !== 'production' && options.twoWay && prop.mode !== propBindingModes.TWO_WAY) { + warn('Prop "' + name + '" expects a two-way binding type.', vm); + } + } else if ((value = getAttr(el, attr)) !== null) { + // has literal binding! + prop.raw = value; + } else if ('development' !== 'production') { + // check possible camelCase prop usage + var lowerCaseName = path.toLowerCase(); + value = /[A-Z\-]/.test(name) && (el.getAttribute(lowerCaseName) || el.getAttribute(':' + lowerCaseName) || el.getAttribute('v-bind:' + lowerCaseName) || el.getAttribute(':' + lowerCaseName + '.once') || el.getAttribute('v-bind:' + lowerCaseName + '.once') || el.getAttribute(':' + lowerCaseName + '.sync') || el.getAttribute('v-bind:' + lowerCaseName + '.sync')); + if (value) { + warn('Possible usage error for prop `' + lowerCaseName + '` - ' + 'did you mean `' + attr + '`? HTML is case-insensitive, remember to use ' + 'kebab-case for props in templates.', vm); + } else if (options.required) { + // warn missing required + warn('Missing required prop: ' + name, vm); + } + } + // push prop + props.push(prop); + } + return makePropsLinkFn(props); + } + + /** + * Build a function that applies props to a vm. + * + * @param {Array} props + * @return {Function} propsLinkFn + */ + + function makePropsLinkFn(props) { + return function propsLinkFn(vm, scope) { + // store resolved props info + vm._props = {}; + var inlineProps = vm.$options.propsData; + var i = props.length; + var prop, path, options, value, raw; + while (i--) { + prop = props[i]; + raw = prop.raw; + path = prop.path; + options = prop.options; + vm._props[path] = prop; + if (inlineProps && hasOwn(inlineProps, path)) { + initProp(vm, prop, inlineProps[path]); + }if (raw === null) { + // initialize absent prop + initProp(vm, prop, undefined); + } else if (prop.dynamic) { + // dynamic prop + if (prop.mode === propBindingModes.ONE_TIME) { + // one time binding + value = (scope || vm._context || vm).$get(prop.parentPath); + initProp(vm, prop, value); + } else { + if (vm._context) { + // dynamic binding + vm._bindDir({ + name: 'prop', + def: propDef, + prop: prop + }, null, null, scope); // el, host, scope + } else { + // root instance + initProp(vm, prop, vm.$get(prop.parentPath)); + } + } + } else if (prop.optimizedLiteral) { + // optimized literal, cast it and just set once + var stripped = stripQuotes(raw); + value = stripped === raw ? toBoolean(toNumber(raw)) : stripped; + initProp(vm, prop, value); + } else { + // string literal, but we need to cater for + // Boolean props with no value, or with same + // literal value (e.g. disabled="disabled") + // see https://github.com/vuejs/vue-loader/issues/182 + value = options.type === Boolean && (raw === '' || raw === hyphenate(prop.name)) ? true : raw; + initProp(vm, prop, value); + } + } + }; + } + + /** + * Process a prop with a rawValue, applying necessary coersions, + * default values & assertions and call the given callback with + * processed value. + * + * @param {Vue} vm + * @param {Object} prop + * @param {*} rawValue + * @param {Function} fn + */ + + function processPropValue(vm, prop, rawValue, fn) { + var isSimple = prop.dynamic && isSimplePath(prop.parentPath); + var value = rawValue; + if (value === undefined) { + value = getPropDefaultValue(vm, prop); + } + value = coerceProp(prop, value, vm); + var coerced = value !== rawValue; + if (!assertProp(prop, value, vm)) { + value = undefined; + } + if (isSimple && !coerced) { + withoutConversion(function () { + fn(value); + }); + } else { + fn(value); + } + } + + /** + * Set a prop's initial value on a vm and its data object. + * + * @param {Vue} vm + * @param {Object} prop + * @param {*} value + */ + + function initProp(vm, prop, value) { + processPropValue(vm, prop, value, function (value) { + defineReactive(vm, prop.path, value); + }); + } + + /** + * Update a prop's value on a vm. + * + * @param {Vue} vm + * @param {Object} prop + * @param {*} value + */ + + function updateProp(vm, prop, value) { + processPropValue(vm, prop, value, function (value) { + vm[prop.path] = value; + }); + } + + /** + * Get the default value of a prop. + * + * @param {Vue} vm + * @param {Object} prop + * @return {*} + */ + + function getPropDefaultValue(vm, prop) { + // no default, return undefined + var options = prop.options; + if (!hasOwn(options, 'default')) { + // absent boolean value defaults to false + return options.type === Boolean ? false : undefined; + } + var def = options['default']; + // warn against non-factory defaults for Object & Array + if (isObject(def)) { + 'development' !== 'production' && warn('Invalid default value for prop "' + prop.name + '": ' + 'Props with type Object/Array must use a factory function ' + 'to return the default value.', vm); + } + // call factory function for non-Function types + return typeof def === 'function' && options.type !== Function ? def.call(vm) : def; + } + + /** + * Assert whether a prop is valid. + * + * @param {Object} prop + * @param {*} value + * @param {Vue} vm + */ + + function assertProp(prop, value, vm) { + if (!prop.options.required && ( // non-required + prop.raw === null || // abscent + value == null) // null or undefined + ) { + return true; + } + var options = prop.options; + var type = options.type; + var valid = !type; + var expectedTypes = []; + if (type) { + if (!isArray(type)) { + type = [type]; + } + for (var i = 0; i < type.length && !valid; i++) { + var assertedType = assertType(value, type[i]); + expectedTypes.push(assertedType.expectedType); + valid = assertedType.valid; + } + } + if (!valid) { + if ('development' !== 'production') { + warn('Invalid prop: type check failed for prop "' + prop.name + '".' + ' Expected ' + expectedTypes.map(formatType).join(', ') + ', got ' + formatValue(value) + '.', vm); + } + return false; + } + var validator = options.validator; + if (validator) { + if (!validator(value)) { + 'development' !== 'production' && warn('Invalid prop: custom validator check failed for prop "' + prop.name + '".', vm); + return false; + } + } + return true; + } + + /** + * Force parsing value with coerce option. + * + * @param {*} value + * @param {Object} options + * @return {*} + */ + + function coerceProp(prop, value, vm) { + var coerce = prop.options.coerce; + if (!coerce) { + return value; + } + if (typeof coerce === 'function') { + return coerce(value); + } else { + 'development' !== 'production' && warn('Invalid coerce for prop "' + prop.name + '": expected function, got ' + typeof coerce + '.', vm); + return value; + } + } + + /** + * Assert the type of a value + * + * @param {*} value + * @param {Function} type + * @return {Object} + */ + + function assertType(value, type) { + var valid; + var expectedType; + if (type === String) { + expectedType = 'string'; + valid = typeof value === expectedType; + } else if (type === Number) { + expectedType = 'number'; + valid = typeof value === expectedType; + } else if (type === Boolean) { + expectedType = 'boolean'; + valid = typeof value === expectedType; + } else if (type === Function) { + expectedType = 'function'; + valid = typeof value === expectedType; + } else if (type === Object) { + expectedType = 'object'; + valid = isPlainObject(value); + } else if (type === Array) { + expectedType = 'array'; + valid = isArray(value); + } else { + valid = value instanceof type; + } + return { + valid: valid, + expectedType: expectedType + }; + } + + /** + * Format type for output + * + * @param {String} type + * @return {String} + */ + + function formatType(type) { + return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'custom type'; + } + + /** + * Format value + * + * @param {*} value + * @return {String} + */ + + function formatValue(val) { + return Object.prototype.toString.call(val).slice(8, -1); + } + + var bindingModes = config._propBindingModes; + + var propDef = { + + bind: function bind() { + var child = this.vm; + var parent = child._context; + // passed in from compiler directly + var prop = this.descriptor.prop; + var childKey = prop.path; + var parentKey = prop.parentPath; + var twoWay = prop.mode === bindingModes.TWO_WAY; + + var parentWatcher = this.parentWatcher = new Watcher(parent, parentKey, function (val) { + updateProp(child, prop, val); + }, { + twoWay: twoWay, + filters: prop.filters, + // important: props need to be observed on the + // v-for scope if present + scope: this._scope + }); + + // set the child initial value. + initProp(child, prop, parentWatcher.value); + + // setup two-way binding + if (twoWay) { + // important: defer the child watcher creation until + // the created hook (after data observation) + var self = this; + child.$once('pre-hook:created', function () { + self.childWatcher = new Watcher(child, childKey, function (val) { + parentWatcher.set(val); + }, { + // ensure sync upward before parent sync down. + // this is necessary in cases e.g. the child + // mutates a prop array, then replaces it. (#1683) + sync: true + }); + }); + } + }, + + unbind: function unbind() { + this.parentWatcher.teardown(); + if (this.childWatcher) { + this.childWatcher.teardown(); + } + } + }; + + var queue$1 = []; + var queued = false; + + /** + * Push a job into the queue. + * + * @param {Function} job + */ + + function pushJob(job) { + queue$1.push(job); + if (!queued) { + queued = true; + nextTick(flush); + } + } + + /** + * Flush the queue, and do one forced reflow before + * triggering transitions. + */ + + function flush() { + // Force layout + var f = document.documentElement.offsetHeight; + for (var i = 0; i < queue$1.length; i++) { + queue$1[i](); + } + queue$1 = []; + queued = false; + // dummy return, so js linters don't complain about + // unused variable f + return f; + } + + var TYPE_TRANSITION = 'transition'; + var TYPE_ANIMATION = 'animation'; + var transDurationProp = transitionProp + 'Duration'; + var animDurationProp = animationProp + 'Duration'; + + /** + * If a just-entered element is applied the + * leave class while its enter transition hasn't started yet, + * and the transitioned property has the same value for both + * enter/leave, then the leave transition will be skipped and + * the transitionend event never fires. This function ensures + * its callback to be called after a transition has started + * by waiting for double raf. + * + * It falls back to setTimeout on devices that support CSS + * transitions but not raf (e.g. Android 4.2 browser) - since + * these environments are usually slow, we are giving it a + * relatively large timeout. + */ + + var raf = inBrowser && window.requestAnimationFrame; + var waitForTransitionStart = raf + /* istanbul ignore next */ + ? function (fn) { + raf(function () { + raf(fn); + }); + } : function (fn) { + setTimeout(fn, 50); + }; + + /** + * A Transition object that encapsulates the state and logic + * of the transition. + * + * @param {Element} el + * @param {String} id + * @param {Object} hooks + * @param {Vue} vm + */ + function Transition(el, id, hooks, vm) { + this.id = id; + this.el = el; + this.enterClass = hooks && hooks.enterClass || id + '-enter'; + this.leaveClass = hooks && hooks.leaveClass || id + '-leave'; + this.hooks = hooks; + this.vm = vm; + // async state + this.pendingCssEvent = this.pendingCssCb = this.cancel = this.pendingJsCb = this.op = this.cb = null; + this.justEntered = false; + this.entered = this.left = false; + this.typeCache = {}; + // check css transition type + this.type = hooks && hooks.type; + /* istanbul ignore if */ + if ('development' !== 'production') { + if (this.type && this.type !== TYPE_TRANSITION && this.type !== TYPE_ANIMATION) { + warn('invalid CSS transition type for transition="' + this.id + '": ' + this.type, vm); + } + } + // bind + var self = this;['enterNextTick', 'enterDone', 'leaveNextTick', 'leaveDone'].forEach(function (m) { + self[m] = bind(self[m], self); + }); + } + + var p$1 = Transition.prototype; + + /** + * Start an entering transition. + * + * 1. enter transition triggered + * 2. call beforeEnter hook + * 3. add enter class + * 4. insert/show element + * 5. call enter hook (with possible explicit js callback) + * 6. reflow + * 7. based on transition type: + * - transition: + * remove class now, wait for transitionend, + * then done if there's no explicit js callback. + * - animation: + * wait for animationend, remove class, + * then done if there's no explicit js callback. + * - no css transition: + * done now if there's no explicit js callback. + * 8. wait for either done or js callback, then call + * afterEnter hook. + * + * @param {Function} op - insert/show the element + * @param {Function} [cb] + */ + + p$1.enter = function (op, cb) { + this.cancelPending(); + this.callHook('beforeEnter'); + this.cb = cb; + addClass(this.el, this.enterClass); + op(); + this.entered = false; + this.callHookWithCb('enter'); + if (this.entered) { + return; // user called done synchronously. + } + this.cancel = this.hooks && this.hooks.enterCancelled; + pushJob(this.enterNextTick); + }; + + /** + * The "nextTick" phase of an entering transition, which is + * to be pushed into a queue and executed after a reflow so + * that removing the class can trigger a CSS transition. + */ + + p$1.enterNextTick = function () { + var _this = this; + + // prevent transition skipping + this.justEntered = true; + waitForTransitionStart(function () { + _this.justEntered = false; + }); + var enterDone = this.enterDone; + var type = this.getCssTransitionType(this.enterClass); + if (!this.pendingJsCb) { + if (type === TYPE_TRANSITION) { + // trigger transition by removing enter class now + removeClass(this.el, this.enterClass); + this.setupCssCb(transitionEndEvent, enterDone); + } else if (type === TYPE_ANIMATION) { + this.setupCssCb(animationEndEvent, enterDone); + } else { + enterDone(); + } + } else if (type === TYPE_TRANSITION) { + removeClass(this.el, this.enterClass); + } + }; + + /** + * The "cleanup" phase of an entering transition. + */ + + p$1.enterDone = function () { + this.entered = true; + this.cancel = this.pendingJsCb = null; + removeClass(this.el, this.enterClass); + this.callHook('afterEnter'); + if (this.cb) this.cb(); + }; + + /** + * Start a leaving transition. + * + * 1. leave transition triggered. + * 2. call beforeLeave hook + * 3. add leave class (trigger css transition) + * 4. call leave hook (with possible explicit js callback) + * 5. reflow if no explicit js callback is provided + * 6. based on transition type: + * - transition or animation: + * wait for end event, remove class, then done if + * there's no explicit js callback. + * - no css transition: + * done if there's no explicit js callback. + * 7. wait for either done or js callback, then call + * afterLeave hook. + * + * @param {Function} op - remove/hide the element + * @param {Function} [cb] + */ + + p$1.leave = function (op, cb) { + this.cancelPending(); + this.callHook('beforeLeave'); + this.op = op; + this.cb = cb; + addClass(this.el, this.leaveClass); + this.left = false; + this.callHookWithCb('leave'); + if (this.left) { + return; // user called done synchronously. + } + this.cancel = this.hooks && this.hooks.leaveCancelled; + // only need to handle leaveDone if + // 1. the transition is already done (synchronously called + // by the user, which causes this.op set to null) + // 2. there's no explicit js callback + if (this.op && !this.pendingJsCb) { + // if a CSS transition leaves immediately after enter, + // the transitionend event never fires. therefore we + // detect such cases and end the leave immediately. + if (this.justEntered) { + this.leaveDone(); + } else { + pushJob(this.leaveNextTick); + } + } + }; + + /** + * The "nextTick" phase of a leaving transition. + */ + + p$1.leaveNextTick = function () { + var type = this.getCssTransitionType(this.leaveClass); + if (type) { + var event = type === TYPE_TRANSITION ? transitionEndEvent : animationEndEvent; + this.setupCssCb(event, this.leaveDone); + } else { + this.leaveDone(); + } + }; + + /** + * The "cleanup" phase of a leaving transition. + */ + + p$1.leaveDone = function () { + this.left = true; + this.cancel = this.pendingJsCb = null; + this.op(); + removeClass(this.el, this.leaveClass); + this.callHook('afterLeave'); + if (this.cb) this.cb(); + this.op = null; + }; + + /** + * Cancel any pending callbacks from a previously running + * but not finished transition. + */ + + p$1.cancelPending = function () { + this.op = this.cb = null; + var hasPending = false; + if (this.pendingCssCb) { + hasPending = true; + off(this.el, this.pendingCssEvent, this.pendingCssCb); + this.pendingCssEvent = this.pendingCssCb = null; + } + if (this.pendingJsCb) { + hasPending = true; + this.pendingJsCb.cancel(); + this.pendingJsCb = null; + } + if (hasPending) { + removeClass(this.el, this.enterClass); + removeClass(this.el, this.leaveClass); + } + if (this.cancel) { + this.cancel.call(this.vm, this.el); + this.cancel = null; + } + }; + + /** + * Call a user-provided synchronous hook function. + * + * @param {String} type + */ + + p$1.callHook = function (type) { + if (this.hooks && this.hooks[type]) { + this.hooks[type].call(this.vm, this.el); + } + }; + + /** + * Call a user-provided, potentially-async hook function. + * We check for the length of arguments to see if the hook + * expects a `done` callback. If true, the transition's end + * will be determined by when the user calls that callback; + * otherwise, the end is determined by the CSS transition or + * animation. + * + * @param {String} type + */ + + p$1.callHookWithCb = function (type) { + var hook = this.hooks && this.hooks[type]; + if (hook) { + if (hook.length > 1) { + this.pendingJsCb = cancellable(this[type + 'Done']); + } + hook.call(this.vm, this.el, this.pendingJsCb); + } + }; + + /** + * Get an element's transition type based on the + * calculated styles. + * + * @param {String} className + * @return {Number} + */ + + p$1.getCssTransitionType = function (className) { + /* istanbul ignore if */ + if (!transitionEndEvent || + // skip CSS transitions if page is not visible - + // this solves the issue of transitionend events not + // firing until the page is visible again. + // pageVisibility API is supported in IE10+, same as + // CSS transitions. + document.hidden || + // explicit js-only transition + this.hooks && this.hooks.css === false || + // element is hidden + isHidden(this.el)) { + return; + } + var type = this.type || this.typeCache[className]; + if (type) return type; + var inlineStyles = this.el.style; + var computedStyles = window.getComputedStyle(this.el); + var transDuration = inlineStyles[transDurationProp] || computedStyles[transDurationProp]; + if (transDuration && transDuration !== '0s') { + type = TYPE_TRANSITION; + } else { + var animDuration = inlineStyles[animDurationProp] || computedStyles[animDurationProp]; + if (animDuration && animDuration !== '0s') { + type = TYPE_ANIMATION; + } + } + if (type) { + this.typeCache[className] = type; + } + return type; + }; + + /** + * Setup a CSS transitionend/animationend callback. + * + * @param {String} event + * @param {Function} cb + */ + + p$1.setupCssCb = function (event, cb) { + this.pendingCssEvent = event; + var self = this; + var el = this.el; + var onEnd = this.pendingCssCb = function (e) { + if (e.target === el) { + off(el, event, onEnd); + self.pendingCssEvent = self.pendingCssCb = null; + if (!self.pendingJsCb && cb) { + cb(); + } + } + }; + on(el, event, onEnd); + }; + + /** + * Check if an element is hidden - in that case we can just + * skip the transition alltogether. + * + * @param {Element} el + * @return {Boolean} + */ + + function isHidden(el) { + if (/svg$/.test(el.namespaceURI)) { + // SVG elements do not have offset(Width|Height) + // so we need to check the client rect + var rect = el.getBoundingClientRect(); + return !(rect.width || rect.height); + } else { + return !(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + } + } + + var transition$1 = { + + priority: TRANSITION, + + update: function update(id, oldId) { + var el = this.el; + // resolve on owner vm + var hooks = resolveAsset(this.vm.$options, 'transitions', id); + id = id || 'v'; + oldId = oldId || 'v'; + el.__v_trans = new Transition(el, id, hooks, this.vm); + removeClass(el, oldId + '-transition'); + addClass(el, id + '-transition'); + } + }; + + var internalDirectives = { + style: style, + 'class': vClass, + component: component, + prop: propDef, + transition: transition$1 + }; + + // special binding prefixes + var bindRE = /^v-bind:|^:/; + var onRE = /^v-on:|^@/; + var dirAttrRE = /^v-([^:]+)(?:$|:(.*)$)/; + var modifierRE = /\.[^\.]+/g; + var transitionRE = /^(v-bind:|:)?transition$/; + + // default directive priority + var DEFAULT_PRIORITY = 1000; + var DEFAULT_TERMINAL_PRIORITY = 2000; + + /** + * Compile a template and return a reusable composite link + * function, which recursively contains more link functions + * inside. This top level compile function would normally + * be called on instance root nodes, but can also be used + * for partial compilation if the partial argument is true. + * + * The returned composite link function, when called, will + * return an unlink function that tearsdown all directives + * created during the linking phase. + * + * @param {Element|DocumentFragment} el + * @param {Object} options + * @param {Boolean} partial + * @return {Function} + */ + + function compile(el, options, partial) { + // link function for the node itself. + var nodeLinkFn = partial || !options._asComponent ? compileNode(el, options) : null; + // link function for the childNodes + var childLinkFn = !(nodeLinkFn && nodeLinkFn.terminal) && !isScript(el) && el.hasChildNodes() ? compileNodeList(el.childNodes, options) : null; + + /** + * A composite linker function to be called on a already + * compiled piece of DOM, which instantiates all directive + * instances. + * + * @param {Vue} vm + * @param {Element|DocumentFragment} el + * @param {Vue} [host] - host vm of transcluded content + * @param {Object} [scope] - v-for scope + * @param {Fragment} [frag] - link context fragment + * @return {Function|undefined} + */ + + return function compositeLinkFn(vm, el, host, scope, frag) { + // cache childNodes before linking parent, fix #657 + var childNodes = toArray(el.childNodes); + // link + var dirs = linkAndCapture(function compositeLinkCapturer() { + if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag); + if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag); + }, vm); + return makeUnlinkFn(vm, dirs); + }; + } + + /** + * Apply a linker to a vm/element pair and capture the + * directives created during the process. + * + * @param {Function} linker + * @param {Vue} vm + */ + + function linkAndCapture(linker, vm) { + /* istanbul ignore if */ + if ('development' === 'production') {} + var originalDirCount = vm._directives.length; + linker(); + var dirs = vm._directives.slice(originalDirCount); + dirs.sort(directiveComparator); + for (var i = 0, l = dirs.length; i < l; i++) { + dirs[i]._bind(); + } + return dirs; + } + + /** + * Directive priority sort comparator + * + * @param {Object} a + * @param {Object} b + */ + + function directiveComparator(a, b) { + a = a.descriptor.def.priority || DEFAULT_PRIORITY; + b = b.descriptor.def.priority || DEFAULT_PRIORITY; + return a > b ? -1 : a === b ? 0 : 1; + } + + /** + * Linker functions return an unlink function that + * tearsdown all directives instances generated during + * the process. + * + * We create unlink functions with only the necessary + * information to avoid retaining additional closures. + * + * @param {Vue} vm + * @param {Array} dirs + * @param {Vue} [context] + * @param {Array} [contextDirs] + * @return {Function} + */ + + function makeUnlinkFn(vm, dirs, context, contextDirs) { + function unlink(destroying) { + teardownDirs(vm, dirs, destroying); + if (context && contextDirs) { + teardownDirs(context, contextDirs); + } + } + // expose linked directives + unlink.dirs = dirs; + return unlink; + } + + /** + * Teardown partial linked directives. + * + * @param {Vue} vm + * @param {Array} dirs + * @param {Boolean} destroying + */ + + function teardownDirs(vm, dirs, destroying) { + var i = dirs.length; + while (i--) { + dirs[i]._teardown(); + if ('development' !== 'production' && !destroying) { + vm._directives.$remove(dirs[i]); + } + } + } + + /** + * Compile link props on an instance. + * + * @param {Vue} vm + * @param {Element} el + * @param {Object} props + * @param {Object} [scope] + * @return {Function} + */ + + function compileAndLinkProps(vm, el, props, scope) { + var propsLinkFn = compileProps(el, props, vm); + var propDirs = linkAndCapture(function () { + propsLinkFn(vm, scope); + }, vm); + return makeUnlinkFn(vm, propDirs); + } + + /** + * Compile the root element of an instance. + * + * 1. attrs on context container (context scope) + * 2. attrs on the component template root node, if + * replace:true (child scope) + * + * If this is a fragment instance, we only need to compile 1. + * + * @param {Element} el + * @param {Object} options + * @param {Object} contextOptions + * @return {Function} + */ + + function compileRoot(el, options, contextOptions) { + var containerAttrs = options._containerAttrs; + var replacerAttrs = options._replacerAttrs; + var contextLinkFn, replacerLinkFn; + + // only need to compile other attributes for + // non-fragment instances + if (el.nodeType !== 11) { + // for components, container and replacer need to be + // compiled separately and linked in different scopes. + if (options._asComponent) { + // 2. container attributes + if (containerAttrs && contextOptions) { + contextLinkFn = compileDirectives(containerAttrs, contextOptions); + } + if (replacerAttrs) { + // 3. replacer attributes + replacerLinkFn = compileDirectives(replacerAttrs, options); + } + } else { + // non-component, just compile as a normal element. + replacerLinkFn = compileDirectives(el.attributes, options); + } + } else if ('development' !== 'production' && containerAttrs) { + // warn container directives for fragment instances + var names = containerAttrs.filter(function (attr) { + // allow vue-loader/vueify scoped css attributes + return attr.name.indexOf('_v-') < 0 && + // allow event listeners + !onRE.test(attr.name) && + // allow slots + attr.name !== 'slot'; + }).map(function (attr) { + return '"' + attr.name + '"'; + }); + if (names.length) { + var plural = names.length > 1; + warn('Attribute' + (plural ? 's ' : ' ') + names.join(', ') + (plural ? ' are' : ' is') + ' ignored on component ' + '<' + options.el.tagName.toLowerCase() + '> because ' + 'the component is a fragment instance: ' + 'http://vuejs.org/guide/components.html#Fragment-Instance'); + } + } + + options._containerAttrs = options._replacerAttrs = null; + return function rootLinkFn(vm, el, scope) { + // link context scope dirs + var context = vm._context; + var contextDirs; + if (context && contextLinkFn) { + contextDirs = linkAndCapture(function () { + contextLinkFn(context, el, null, scope); + }, context); + } + + // link self + var selfDirs = linkAndCapture(function () { + if (replacerLinkFn) replacerLinkFn(vm, el); + }, vm); + + // return the unlink function that tearsdown context + // container directives. + return makeUnlinkFn(vm, selfDirs, context, contextDirs); + }; + } + + /** + * Compile a node and return a nodeLinkFn based on the + * node type. + * + * @param {Node} node + * @param {Object} options + * @return {Function|null} + */ + + function compileNode(node, options) { + var type = node.nodeType; + if (type === 1 && !isScript(node)) { + return compileElement(node, options); + } else if (type === 3 && node.data.trim()) { + return compileTextNode(node, options); + } else { + return null; + } + } + + /** + * Compile an element and return a nodeLinkFn. + * + * @param {Element} el + * @param {Object} options + * @return {Function|null} + */ + + function compileElement(el, options) { + // preprocess textareas. + // textarea treats its text content as the initial value. + // just bind it as an attr directive for value. + if (el.tagName === 'TEXTAREA') { + var tokens = parseText(el.value); + if (tokens) { + el.setAttribute(':value', tokensToExp(tokens)); + el.value = ''; + } + } + var linkFn; + var hasAttrs = el.hasAttributes(); + var attrs = hasAttrs && toArray(el.attributes); + // check terminal directives (for & if) + if (hasAttrs) { + linkFn = checkTerminalDirectives(el, attrs, options); + } + // check element directives + if (!linkFn) { + linkFn = checkElementDirectives(el, options); + } + // check component + if (!linkFn) { + linkFn = checkComponent(el, options); + } + // normal directives + if (!linkFn && hasAttrs) { + linkFn = compileDirectives(attrs, options); + } + return linkFn; + } + + /** + * Compile a textNode and return a nodeLinkFn. + * + * @param {TextNode} node + * @param {Object} options + * @return {Function|null} textNodeLinkFn + */ + + function compileTextNode(node, options) { + // skip marked text nodes + if (node._skip) { + return removeText; + } + + var tokens = parseText(node.wholeText); + if (!tokens) { + return null; + } + + // mark adjacent text nodes as skipped, + // because we are using node.wholeText to compile + // all adjacent text nodes together. This fixes + // issues in IE where sometimes it splits up a single + // text node into multiple ones. + var next = node.nextSibling; + while (next && next.nodeType === 3) { + next._skip = true; + next = next.nextSibling; + } + + var frag = document.createDocumentFragment(); + var el, token; + for (var i = 0, l = tokens.length; i < l; i++) { + token = tokens[i]; + el = token.tag ? processTextToken(token, options) : document.createTextNode(token.value); + frag.appendChild(el); + } + return makeTextNodeLinkFn(tokens, frag, options); + } + + /** + * Linker for an skipped text node. + * + * @param {Vue} vm + * @param {Text} node + */ + + function removeText(vm, node) { + remove(node); + } + + /** + * Process a single text token. + * + * @param {Object} token + * @param {Object} options + * @return {Node} + */ + + function processTextToken(token, options) { + var el; + if (token.oneTime) { + el = document.createTextNode(token.value); + } else { + if (token.html) { + el = document.createComment('v-html'); + setTokenType('html'); + } else { + // IE will clean up empty textNodes during + // frag.cloneNode(true), so we have to give it + // something here... + el = document.createTextNode(' '); + setTokenType('text'); + } + } + function setTokenType(type) { + if (token.descriptor) return; + var parsed = parseDirective(token.value); + token.descriptor = { + name: type, + def: directives[type], + expression: parsed.expression, + filters: parsed.filters + }; + } + return el; + } + + /** + * Build a function that processes a textNode. + * + * @param {Array<Object>} tokens + * @param {DocumentFragment} frag + */ + + function makeTextNodeLinkFn(tokens, frag) { + return function textNodeLinkFn(vm, el, host, scope) { + var fragClone = frag.cloneNode(true); + var childNodes = toArray(fragClone.childNodes); + var token, value, node; + for (var i = 0, l = tokens.length; i < l; i++) { + token = tokens[i]; + value = token.value; + if (token.tag) { + node = childNodes[i]; + if (token.oneTime) { + value = (scope || vm).$eval(value); + if (token.html) { + replace(node, parseTemplate(value, true)); + } else { + node.data = _toString(value); + } + } else { + vm._bindDir(token.descriptor, node, host, scope); + } + } + } + replace(el, fragClone); + }; + } + + /** + * Compile a node list and return a childLinkFn. + * + * @param {NodeList} nodeList + * @param {Object} options + * @return {Function|undefined} + */ + + function compileNodeList(nodeList, options) { + var linkFns = []; + var nodeLinkFn, childLinkFn, node; + for (var i = 0, l = nodeList.length; i < l; i++) { + node = nodeList[i]; + nodeLinkFn = compileNode(node, options); + childLinkFn = !(nodeLinkFn && nodeLinkFn.terminal) && node.tagName !== 'SCRIPT' && node.hasChildNodes() ? compileNodeList(node.childNodes, options) : null; + linkFns.push(nodeLinkFn, childLinkFn); + } + return linkFns.length ? makeChildLinkFn(linkFns) : null; + } + + /** + * Make a child link function for a node's childNodes. + * + * @param {Array<Function>} linkFns + * @return {Function} childLinkFn + */ + + function makeChildLinkFn(linkFns) { + return function childLinkFn(vm, nodes, host, scope, frag) { + var node, nodeLinkFn, childrenLinkFn; + for (var i = 0, n = 0, l = linkFns.length; i < l; n++) { + node = nodes[n]; + nodeLinkFn = linkFns[i++]; + childrenLinkFn = linkFns[i++]; + // cache childNodes before linking parent, fix #657 + var childNodes = toArray(node.childNodes); + if (nodeLinkFn) { + nodeLinkFn(vm, node, host, scope, frag); + } + if (childrenLinkFn) { + childrenLinkFn(vm, childNodes, host, scope, frag); + } + } + }; + } + + /** + * Check for element directives (custom elements that should + * be resovled as terminal directives). + * + * @param {Element} el + * @param {Object} options + */ + + function checkElementDirectives(el, options) { + var tag = el.tagName.toLowerCase(); + if (commonTagRE.test(tag)) { + return; + } + var def = resolveAsset(options, 'elementDirectives', tag); + if (def) { + return makeTerminalNodeLinkFn(el, tag, '', options, def); + } + } + + /** + * Check if an element is a component. If yes, return + * a component link function. + * + * @param {Element} el + * @param {Object} options + * @return {Function|undefined} + */ + + function checkComponent(el, options) { + var component = checkComponentAttr(el, options); + if (component) { + var ref = findRef(el); + var descriptor = { + name: 'component', + ref: ref, + expression: component.id, + def: internalDirectives.component, + modifiers: { + literal: !component.dynamic + } + }; + var componentLinkFn = function componentLinkFn(vm, el, host, scope, frag) { + if (ref) { + defineReactive((scope || vm).$refs, ref, null); + } + vm._bindDir(descriptor, el, host, scope, frag); + }; + componentLinkFn.terminal = true; + return componentLinkFn; + } + } + + /** + * Check an element for terminal directives in fixed order. + * If it finds one, return a terminal link function. + * + * @param {Element} el + * @param {Array} attrs + * @param {Object} options + * @return {Function} terminalLinkFn + */ + + function checkTerminalDirectives(el, attrs, options) { + // skip v-pre + if (getAttr(el, 'v-pre') !== null) { + return skip; + } + // skip v-else block, but only if following v-if + if (el.hasAttribute('v-else')) { + var prev = el.previousElementSibling; + if (prev && prev.hasAttribute('v-if')) { + return skip; + } + } + + var attr, name, value, modifiers, matched, dirName, rawName, arg, def, termDef; + for (var i = 0, j = attrs.length; i < j; i++) { + attr = attrs[i]; + name = attr.name.replace(modifierRE, ''); + if (matched = name.match(dirAttrRE)) { + def = resolveAsset(options, 'directives', matched[1]); + if (def && def.terminal) { + if (!termDef || (def.priority || DEFAULT_TERMINAL_PRIORITY) > termDef.priority) { + termDef = def; + rawName = attr.name; + modifiers = parseModifiers(attr.name); + value = attr.value; + dirName = matched[1]; + arg = matched[2]; + } + } + } + } + + if (termDef) { + return makeTerminalNodeLinkFn(el, dirName, value, options, termDef, rawName, arg, modifiers); + } + } + + function skip() {} + skip.terminal = true; + + /** + * Build a node link function for a terminal directive. + * A terminal link function terminates the current + * compilation recursion and handles compilation of the + * subtree in the directive. + * + * @param {Element} el + * @param {String} dirName + * @param {String} value + * @param {Object} options + * @param {Object} def + * @param {String} [rawName] + * @param {String} [arg] + * @param {Object} [modifiers] + * @return {Function} terminalLinkFn + */ + + function makeTerminalNodeLinkFn(el, dirName, value, options, def, rawName, arg, modifiers) { + var parsed = parseDirective(value); + var descriptor = { + name: dirName, + arg: arg, + expression: parsed.expression, + filters: parsed.filters, + raw: value, + attr: rawName, + modifiers: modifiers, + def: def + }; + // check ref for v-for and router-view + if (dirName === 'for' || dirName === 'router-view') { + descriptor.ref = findRef(el); + } + var fn = function terminalNodeLinkFn(vm, el, host, scope, frag) { + if (descriptor.ref) { + defineReactive((scope || vm).$refs, descriptor.ref, null); + } + vm._bindDir(descriptor, el, host, scope, frag); + }; + fn.terminal = true; + return fn; + } + + /** + * Compile the directives on an element and return a linker. + * + * @param {Array|NamedNodeMap} attrs + * @param {Object} options + * @return {Function} + */ + + function compileDirectives(attrs, options) { + var i = attrs.length; + var dirs = []; + var attr, name, value, rawName, rawValue, dirName, arg, modifiers, dirDef, tokens, matched; + while (i--) { + attr = attrs[i]; + name = rawName = attr.name; + value = rawValue = attr.value; + tokens = parseText(value); + // reset arg + arg = null; + // check modifiers + modifiers = parseModifiers(name); + name = name.replace(modifierRE, ''); + + // attribute interpolations + if (tokens) { + value = tokensToExp(tokens); + arg = name; + pushDir('bind', directives.bind, tokens); + // warn against mixing mustaches with v-bind + if ('development' !== 'production') { + if (name === 'class' && Array.prototype.some.call(attrs, function (attr) { + return attr.name === ':class' || attr.name === 'v-bind:class'; + })) { + warn('class="' + rawValue + '": Do not mix mustache interpolation ' + 'and v-bind for "class" on the same element. Use one or the other.', options); + } + } + } else + + // special attribute: transition + if (transitionRE.test(name)) { + modifiers.literal = !bindRE.test(name); + pushDir('transition', internalDirectives.transition); + } else + + // event handlers + if (onRE.test(name)) { + arg = name.replace(onRE, ''); + pushDir('on', directives.on); + } else + + // attribute bindings + if (bindRE.test(name)) { + dirName = name.replace(bindRE, ''); + if (dirName === 'style' || dirName === 'class') { + pushDir(dirName, internalDirectives[dirName]); + } else { + arg = dirName; + pushDir('bind', directives.bind); + } + } else + + // normal directives + if (matched = name.match(dirAttrRE)) { + dirName = matched[1]; + arg = matched[2]; + + // skip v-else (when used with v-show) + if (dirName === 'else') { + continue; + } + + dirDef = resolveAsset(options, 'directives', dirName, true); + if (dirDef) { + pushDir(dirName, dirDef); + } + } + } + + /** + * Push a directive. + * + * @param {String} dirName + * @param {Object|Function} def + * @param {Array} [interpTokens] + */ + + function pushDir(dirName, def, interpTokens) { + var hasOneTimeToken = interpTokens && hasOneTime(interpTokens); + var parsed = !hasOneTimeToken && parseDirective(value); + dirs.push({ + name: dirName, + attr: rawName, + raw: rawValue, + def: def, + arg: arg, + modifiers: modifiers, + // conversion from interpolation strings with one-time token + // to expression is differed until directive bind time so that we + // have access to the actual vm context for one-time bindings. + expression: parsed && parsed.expression, + filters: parsed && parsed.filters, + interp: interpTokens, + hasOneTime: hasOneTimeToken + }); + } + + if (dirs.length) { + return makeNodeLinkFn(dirs); + } + } + + /** + * Parse modifiers from directive attribute name. + * + * @param {String} name + * @return {Object} + */ + + function parseModifiers(name) { + var res = Object.create(null); + var match = name.match(modifierRE); + if (match) { + var i = match.length; + while (i--) { + res[match[i].slice(1)] = true; + } + } + return res; + } + + /** + * Build a link function for all directives on a single node. + * + * @param {Array} directives + * @return {Function} directivesLinkFn + */ + + function makeNodeLinkFn(directives) { + return function nodeLinkFn(vm, el, host, scope, frag) { + // reverse apply because it's sorted low to high + var i = directives.length; + while (i--) { + vm._bindDir(directives[i], el, host, scope, frag); + } + }; + } + + /** + * Check if an interpolation string contains one-time tokens. + * + * @param {Array} tokens + * @return {Boolean} + */ + + function hasOneTime(tokens) { + var i = tokens.length; + while (i--) { + if (tokens[i].oneTime) return true; + } + } + + function isScript(el) { + return el.tagName === 'SCRIPT' && (!el.hasAttribute('type') || el.getAttribute('type') === 'text/javascript'); + } + + var specialCharRE = /[^\w\-:\.]/; + + /** + * Process an element or a DocumentFragment based on a + * instance option object. This allows us to transclude + * a template node/fragment before the instance is created, + * so the processed fragment can then be cloned and reused + * in v-for. + * + * @param {Element} el + * @param {Object} options + * @return {Element|DocumentFragment} + */ + + function transclude(el, options) { + // extract container attributes to pass them down + // to compiler, because they need to be compiled in + // parent scope. we are mutating the options object here + // assuming the same object will be used for compile + // right after this. + if (options) { + options._containerAttrs = extractAttrs(el); + } + // for template tags, what we want is its content as + // a documentFragment (for fragment instances) + if (isTemplate(el)) { + el = parseTemplate(el); + } + if (options) { + if (options._asComponent && !options.template) { + options.template = '<slot></slot>'; + } + if (options.template) { + options._content = extractContent(el); + el = transcludeTemplate(el, options); + } + } + if (isFragment(el)) { + // anchors for fragment instance + // passing in `persist: true` to avoid them being + // discarded by IE during template cloning + prepend(createAnchor('v-start', true), el); + el.appendChild(createAnchor('v-end', true)); + } + return el; + } + + /** + * Process the template option. + * If the replace option is true this will swap the $el. + * + * @param {Element} el + * @param {Object} options + * @return {Element|DocumentFragment} + */ + + function transcludeTemplate(el, options) { + var template = options.template; + var frag = parseTemplate(template, true); + if (frag) { + var replacer = frag.firstChild; + var tag = replacer.tagName && replacer.tagName.toLowerCase(); + if (options.replace) { + /* istanbul ignore if */ + if (el === document.body) { + 'development' !== 'production' && warn('You are mounting an instance with a template to ' + '<body>. This will replace <body> entirely. You ' + 'should probably use `replace: false` here.'); + } + // there are many cases where the instance must + // become a fragment instance: basically anything that + // can create more than 1 root nodes. + if ( + // multi-children template + frag.childNodes.length > 1 || + // non-element template + replacer.nodeType !== 1 || + // single nested component + tag === 'component' || resolveAsset(options, 'components', tag) || hasBindAttr(replacer, 'is') || + // element directive + resolveAsset(options, 'elementDirectives', tag) || + // for block + replacer.hasAttribute('v-for') || + // if block + replacer.hasAttribute('v-if')) { + return frag; + } else { + options._replacerAttrs = extractAttrs(replacer); + mergeAttrs(el, replacer); + return replacer; + } + } else { + el.appendChild(frag); + return el; + } + } else { + 'development' !== 'production' && warn('Invalid template option: ' + template); + } + } + + /** + * Helper to extract a component container's attributes + * into a plain object array. + * + * @param {Element} el + * @return {Array} + */ + + function extractAttrs(el) { + if (el.nodeType === 1 && el.hasAttributes()) { + return toArray(el.attributes); + } + } + + /** + * Merge the attributes of two elements, and make sure + * the class names are merged properly. + * + * @param {Element} from + * @param {Element} to + */ + + function mergeAttrs(from, to) { + var attrs = from.attributes; + var i = attrs.length; + var name, value; + while (i--) { + name = attrs[i].name; + value = attrs[i].value; + if (!to.hasAttribute(name) && !specialCharRE.test(name)) { + to.setAttribute(name, value); + } else if (name === 'class' && !parseText(value) && (value = value.trim())) { + value.split(/\s+/).forEach(function (cls) { + addClass(to, cls); + }); + } + } + } + + /** + * Scan and determine slot content distribution. + * We do this during transclusion instead at compile time so that + * the distribution is decoupled from the compilation order of + * the slots. + * + * @param {Element|DocumentFragment} template + * @param {Element} content + * @param {Vue} vm + */ + + function resolveSlots(vm, content) { + if (!content) { + return; + } + var contents = vm._slotContents = Object.create(null); + var el, name; + for (var i = 0, l = content.children.length; i < l; i++) { + el = content.children[i]; + /* eslint-disable no-cond-assign */ + if (name = el.getAttribute('slot')) { + (contents[name] || (contents[name] = [])).push(el); + } + /* eslint-enable no-cond-assign */ + if ('development' !== 'production' && getBindAttr(el, 'slot')) { + warn('The "slot" attribute must be static.', vm.$parent); + } + } + for (name in contents) { + contents[name] = extractFragment(contents[name], content); + } + if (content.hasChildNodes()) { + var nodes = content.childNodes; + if (nodes.length === 1 && nodes[0].nodeType === 3 && !nodes[0].data.trim()) { + return; + } + contents['default'] = extractFragment(content.childNodes, content); + } + } + + /** + * Extract qualified content nodes from a node list. + * + * @param {NodeList} nodes + * @return {DocumentFragment} + */ + + function extractFragment(nodes, parent) { + var frag = document.createDocumentFragment(); + nodes = toArray(nodes); + for (var i = 0, l = nodes.length; i < l; i++) { + var node = nodes[i]; + if (isTemplate(node) && !node.hasAttribute('v-if') && !node.hasAttribute('v-for')) { + parent.removeChild(node); + node = parseTemplate(node, true); + } + frag.appendChild(node); + } + return frag; + } + + + + var compiler = Object.freeze({ + compile: compile, + compileAndLinkProps: compileAndLinkProps, + compileRoot: compileRoot, + transclude: transclude, + resolveSlots: resolveSlots + }); + + function stateMixin (Vue) { + /** + * Accessor for `$data` property, since setting $data + * requires observing the new object and updating + * proxied properties. + */ + + Object.defineProperty(Vue.prototype, '$data', { + get: function get() { + return this._data; + }, + set: function set(newData) { + if (newData !== this._data) { + this._setData(newData); + } + } + }); + + /** + * Setup the scope of an instance, which contains: + * - observed data + * - computed properties + * - user methods + * - meta properties + */ + + Vue.prototype._initState = function () { + this._initProps(); + this._initMeta(); + this._initMethods(); + this._initData(); + this._initComputed(); + }; + + /** + * Initialize props. + */ + + Vue.prototype._initProps = function () { + var options = this.$options; + var el = options.el; + var props = options.props; + if (props && !el) { + 'development' !== 'production' && warn('Props will not be compiled if no `el` option is ' + 'provided at instantiation.', this); + } + // make sure to convert string selectors into element now + el = options.el = query(el); + this._propsUnlinkFn = el && el.nodeType === 1 && props + // props must be linked in proper scope if inside v-for + ? compileAndLinkProps(this, el, props, this._scope) : null; + }; + + /** + * Initialize the data. + */ + + Vue.prototype._initData = function () { + var dataFn = this.$options.data; + var data = this._data = dataFn ? dataFn() : {}; + if (!isPlainObject(data)) { + data = {}; + 'development' !== 'production' && warn('data functions should return an object.', this); + } + var props = this._props; + // proxy data on instance + var keys = Object.keys(data); + var i, key; + i = keys.length; + while (i--) { + key = keys[i]; + // there are two scenarios where we can proxy a data key: + // 1. it's not already defined as a prop + // 2. it's provided via a instantiation option AND there are no + // template prop present + if (!props || !hasOwn(props, key)) { + this._proxy(key); + } else if ('development' !== 'production') { + warn('Data field "' + key + '" is already defined ' + 'as a prop. To provide default value for a prop, use the "default" ' + 'prop option; if you want to pass prop values to an instantiation ' + 'call, use the "propsData" option.', this); + } + } + // observe data + observe(data, this); + }; + + /** + * Swap the instance's $data. Called in $data's setter. + * + * @param {Object} newData + */ + + Vue.prototype._setData = function (newData) { + newData = newData || {}; + var oldData = this._data; + this._data = newData; + var keys, key, i; + // unproxy keys not present in new data + keys = Object.keys(oldData); + i = keys.length; + while (i--) { + key = keys[i]; + if (!(key in newData)) { + this._unproxy(key); + } + } + // proxy keys not already proxied, + // and trigger change for changed values + keys = Object.keys(newData); + i = keys.length; + while (i--) { + key = keys[i]; + if (!hasOwn(this, key)) { + // new property + this._proxy(key); + } + } + oldData.__ob__.removeVm(this); + observe(newData, this); + this._digest(); + }; + + /** + * Proxy a property, so that + * vm.prop === vm._data.prop + * + * @param {String} key + */ + + Vue.prototype._proxy = function (key) { + if (!isReserved(key)) { + // need to store ref to self here + // because these getter/setters might + // be called by child scopes via + // prototype inheritance. + var self = this; + Object.defineProperty(self, key, { + configurable: true, + enumerable: true, + get: function proxyGetter() { + return self._data[key]; + }, + set: function proxySetter(val) { + self._data[key] = val; + } + }); + } + }; + + /** + * Unproxy a property. + * + * @param {String} key + */ + + Vue.prototype._unproxy = function (key) { + if (!isReserved(key)) { + delete this[key]; + } + }; + + /** + * Force update on every watcher in scope. + */ + + Vue.prototype._digest = function () { + for (var i = 0, l = this._watchers.length; i < l; i++) { + this._watchers[i].update(true); // shallow updates + } + }; + + /** + * Setup computed properties. They are essentially + * special getter/setters + */ + + function noop() {} + Vue.prototype._initComputed = function () { + var computed = this.$options.computed; + if (computed) { + for (var key in computed) { + var userDef = computed[key]; + var def = { + enumerable: true, + configurable: true + }; + if (typeof userDef === 'function') { + def.get = makeComputedGetter(userDef, this); + def.set = noop; + } else { + def.get = userDef.get ? userDef.cache !== false ? makeComputedGetter(userDef.get, this) : bind(userDef.get, this) : noop; + def.set = userDef.set ? bind(userDef.set, this) : noop; + } + Object.defineProperty(this, key, def); + } + } + }; + + function makeComputedGetter(getter, owner) { + var watcher = new Watcher(owner, getter, null, { + lazy: true + }); + return function computedGetter() { + if (watcher.dirty) { + watcher.evaluate(); + } + if (Dep.target) { + watcher.depend(); + } + return watcher.value; + }; + } + + /** + * Setup instance methods. Methods must be bound to the + * instance since they might be passed down as a prop to + * child components. + */ + + Vue.prototype._initMethods = function () { + var methods = this.$options.methods; + if (methods) { + for (var key in methods) { + this[key] = bind(methods[key], this); + } + } + }; + + /** + * Initialize meta information like $index, $key & $value. + */ + + Vue.prototype._initMeta = function () { + var metas = this.$options._meta; + if (metas) { + for (var key in metas) { + defineReactive(this, key, metas[key]); + } + } + }; + } + + var eventRE = /^v-on:|^@/; + + function eventsMixin (Vue) { + /** + * Setup the instance's option events & watchers. + * If the value is a string, we pull it from the + * instance's methods by name. + */ + + Vue.prototype._initEvents = function () { + var options = this.$options; + if (options._asComponent) { + registerComponentEvents(this, options.el); + } + registerCallbacks(this, '$on', options.events); + registerCallbacks(this, '$watch', options.watch); + }; + + /** + * Register v-on events on a child component + * + * @param {Vue} vm + * @param {Element} el + */ + + function registerComponentEvents(vm, el) { + var attrs = el.attributes; + var name, value, handler; + for (var i = 0, l = attrs.length; i < l; i++) { + name = attrs[i].name; + if (eventRE.test(name)) { + name = name.replace(eventRE, ''); + // force the expression into a statement so that + // it always dynamically resolves the method to call (#2670) + // kinda ugly hack, but does the job. + value = attrs[i].value; + if (isSimplePath(value)) { + value += '.apply(this, $arguments)'; + } + handler = (vm._scope || vm._context).$eval(value, true); + handler._fromParent = true; + vm.$on(name.replace(eventRE), handler); + } + } + } + + /** + * Register callbacks for option events and watchers. + * + * @param {Vue} vm + * @param {String} action + * @param {Object} hash + */ + + function registerCallbacks(vm, action, hash) { + if (!hash) return; + var handlers, key, i, j; + for (key in hash) { + handlers = hash[key]; + if (isArray(handlers)) { + for (i = 0, j = handlers.length; i < j; i++) { + register(vm, action, key, handlers[i]); + } + } else { + register(vm, action, key, handlers); + } + } + } + + /** + * Helper to register an event/watch callback. + * + * @param {Vue} vm + * @param {String} action + * @param {String} key + * @param {Function|String|Object} handler + * @param {Object} [options] + */ + + function register(vm, action, key, handler, options) { + var type = typeof handler; + if (type === 'function') { + vm[action](key, handler, options); + } else if (type === 'string') { + var methods = vm.$options.methods; + var method = methods && methods[handler]; + if (method) { + vm[action](key, method, options); + } else { + 'development' !== 'production' && warn('Unknown method: "' + handler + '" when ' + 'registering callback for ' + action + ': "' + key + '".', vm); + } + } else if (handler && type === 'object') { + register(vm, action, key, handler.handler, handler); + } + } + + /** + * Setup recursive attached/detached calls + */ + + Vue.prototype._initDOMHooks = function () { + this.$on('hook:attached', onAttached); + this.$on('hook:detached', onDetached); + }; + + /** + * Callback to recursively call attached hook on children + */ + + function onAttached() { + if (!this._isAttached) { + this._isAttached = true; + this.$children.forEach(callAttach); + } + } + + /** + * Iterator to call attached hook + * + * @param {Vue} child + */ + + function callAttach(child) { + if (!child._isAttached && inDoc(child.$el)) { + child._callHook('attached'); + } + } + + /** + * Callback to recursively call detached hook on children + */ + + function onDetached() { + if (this._isAttached) { + this._isAttached = false; + this.$children.forEach(callDetach); + } + } + + /** + * Iterator to call detached hook + * + * @param {Vue} child + */ + + function callDetach(child) { + if (child._isAttached && !inDoc(child.$el)) { + child._callHook('detached'); + } + } + + /** + * Trigger all handlers for a hook + * + * @param {String} hook + */ + + Vue.prototype._callHook = function (hook) { + this.$emit('pre-hook:' + hook); + var handlers = this.$options[hook]; + if (handlers) { + for (var i = 0, j = handlers.length; i < j; i++) { + handlers[i].call(this); + } + } + this.$emit('hook:' + hook); + }; + } + + function noop$1() {} + + /** + * A directive links a DOM element with a piece of data, + * which is the result of evaluating an expression. + * It registers a watcher with the expression and calls + * the DOM update function when a change is triggered. + * + * @param {Object} descriptor + * - {String} name + * - {Object} def + * - {String} expression + * - {Array<Object>} [filters] + * - {Object} [modifiers] + * - {Boolean} literal + * - {String} attr + * - {String} arg + * - {String} raw + * - {String} [ref] + * - {Array<Object>} [interp] + * - {Boolean} [hasOneTime] + * @param {Vue} vm + * @param {Node} el + * @param {Vue} [host] - transclusion host component + * @param {Object} [scope] - v-for scope + * @param {Fragment} [frag] - owner fragment + * @constructor + */ + function Directive(descriptor, vm, el, host, scope, frag) { + this.vm = vm; + this.el = el; + // copy descriptor properties + this.descriptor = descriptor; + this.name = descriptor.name; + this.expression = descriptor.expression; + this.arg = descriptor.arg; + this.modifiers = descriptor.modifiers; + this.filters = descriptor.filters; + this.literal = this.modifiers && this.modifiers.literal; + // private + this._locked = false; + this._bound = false; + this._listeners = null; + // link context + this._host = host; + this._scope = scope; + this._frag = frag; + // store directives on node in dev mode + if ('development' !== 'production' && this.el) { + this.el._vue_directives = this.el._vue_directives || []; + this.el._vue_directives.push(this); + } + } + + /** + * Initialize the directive, mixin definition properties, + * setup the watcher, call definition bind() and update() + * if present. + */ + + Directive.prototype._bind = function () { + var name = this.name; + var descriptor = this.descriptor; + + // remove attribute + if ((name !== 'cloak' || this.vm._isCompiled) && this.el && this.el.removeAttribute) { + var attr = descriptor.attr || 'v-' + name; + this.el.removeAttribute(attr); + } + + // copy def properties + var def = descriptor.def; + if (typeof def === 'function') { + this.update = def; + } else { + extend(this, def); + } + + // setup directive params + this._setupParams(); + + // initial bind + if (this.bind) { + this.bind(); + } + this._bound = true; + + if (this.literal) { + this.update && this.update(descriptor.raw); + } else if ((this.expression || this.modifiers) && (this.update || this.twoWay) && !this._checkStatement()) { + // wrapped updater for context + var dir = this; + if (this.update) { + this._update = function (val, oldVal) { + if (!dir._locked) { + dir.update(val, oldVal); + } + }; + } else { + this._update = noop$1; + } + var preProcess = this._preProcess ? bind(this._preProcess, this) : null; + var postProcess = this._postProcess ? bind(this._postProcess, this) : null; + var watcher = this._watcher = new Watcher(this.vm, this.expression, this._update, // callback + { + filters: this.filters, + twoWay: this.twoWay, + deep: this.deep, + preProcess: preProcess, + postProcess: postProcess, + scope: this._scope + }); + // v-model with inital inline value need to sync back to + // model instead of update to DOM on init. They would + // set the afterBind hook to indicate that. + if (this.afterBind) { + this.afterBind(); + } else if (this.update) { + this.update(watcher.value); + } + } + }; + + /** + * Setup all param attributes, e.g. track-by, + * transition-mode, etc... + */ + + Directive.prototype._setupParams = function () { + if (!this.params) { + return; + } + var params = this.params; + // swap the params array with a fresh object. + this.params = Object.create(null); + var i = params.length; + var key, val, mappedKey; + while (i--) { + key = hyphenate(params[i]); + mappedKey = camelize(key); + val = getBindAttr(this.el, key); + if (val != null) { + // dynamic + this._setupParamWatcher(mappedKey, val); + } else { + // static + val = getAttr(this.el, key); + if (val != null) { + this.params[mappedKey] = val === '' ? true : val; + } + } + } + }; + + /** + * Setup a watcher for a dynamic param. + * + * @param {String} key + * @param {String} expression + */ + + Directive.prototype._setupParamWatcher = function (key, expression) { + var self = this; + var called = false; + var unwatch = (this._scope || this.vm).$watch(expression, function (val, oldVal) { + self.params[key] = val; + // since we are in immediate mode, + // only call the param change callbacks if this is not the first update. + if (called) { + var cb = self.paramWatchers && self.paramWatchers[key]; + if (cb) { + cb.call(self, val, oldVal); + } + } else { + called = true; + } + }, { + immediate: true, + user: false + });(this._paramUnwatchFns || (this._paramUnwatchFns = [])).push(unwatch); + }; + + /** + * Check if the directive is a function caller + * and if the expression is a callable one. If both true, + * we wrap up the expression and use it as the event + * handler. + * + * e.g. on-click="a++" + * + * @return {Boolean} + */ + + Directive.prototype._checkStatement = function () { + var expression = this.expression; + if (expression && this.acceptStatement && !isSimplePath(expression)) { + var fn = parseExpression(expression).get; + var scope = this._scope || this.vm; + var handler = function handler(e) { + scope.$event = e; + fn.call(scope, scope); + scope.$event = null; + }; + if (this.filters) { + handler = scope._applyFilters(handler, null, this.filters); + } + this.update(handler); + return true; + } + }; + + /** + * Set the corresponding value with the setter. + * This should only be used in two-way directives + * e.g. v-model. + * + * @param {*} value + * @public + */ + + Directive.prototype.set = function (value) { + /* istanbul ignore else */ + if (this.twoWay) { + this._withLock(function () { + this._watcher.set(value); + }); + } else if ('development' !== 'production') { + warn('Directive.set() can only be used inside twoWay' + 'directives.'); + } + }; + + /** + * Execute a function while preventing that function from + * triggering updates on this directive instance. + * + * @param {Function} fn + */ + + Directive.prototype._withLock = function (fn) { + var self = this; + self._locked = true; + fn.call(self); + nextTick(function () { + self._locked = false; + }); + }; + + /** + * Convenience method that attaches a DOM event listener + * to the directive element and autometically tears it down + * during unbind. + * + * @param {String} event + * @param {Function} handler + * @param {Boolean} [useCapture] + */ + + Directive.prototype.on = function (event, handler, useCapture) { + on(this.el, event, handler, useCapture);(this._listeners || (this._listeners = [])).push([event, handler]); + }; + + /** + * Teardown the watcher and call unbind. + */ + + Directive.prototype._teardown = function () { + if (this._bound) { + this._bound = false; + if (this.unbind) { + this.unbind(); + } + if (this._watcher) { + this._watcher.teardown(); + } + var listeners = this._listeners; + var i; + if (listeners) { + i = listeners.length; + while (i--) { + off(this.el, listeners[i][0], listeners[i][1]); + } + } + var unwatchFns = this._paramUnwatchFns; + if (unwatchFns) { + i = unwatchFns.length; + while (i--) { + unwatchFns[i](); + } + } + if ('development' !== 'production' && this.el) { + this.el._vue_directives.$remove(this); + } + this.vm = this.el = this._watcher = this._listeners = null; + } + }; + + function lifecycleMixin (Vue) { + /** + * Update v-ref for component. + * + * @param {Boolean} remove + */ + + Vue.prototype._updateRef = function (remove) { + var ref = this.$options._ref; + if (ref) { + var refs = (this._scope || this._context).$refs; + if (remove) { + if (refs[ref] === this) { + refs[ref] = null; + } + } else { + refs[ref] = this; + } + } + }; + + /** + * Transclude, compile and link element. + * + * If a pre-compiled linker is available, that means the + * passed in element will be pre-transcluded and compiled + * as well - all we need to do is to call the linker. + * + * Otherwise we need to call transclude/compile/link here. + * + * @param {Element} el + */ + + Vue.prototype._compile = function (el) { + var options = this.$options; + + // transclude and init element + // transclude can potentially replace original + // so we need to keep reference; this step also injects + // the template and caches the original attributes + // on the container node and replacer node. + var original = el; + el = transclude(el, options); + this._initElement(el); + + // handle v-pre on root node (#2026) + if (el.nodeType === 1 && getAttr(el, 'v-pre') !== null) { + return; + } + + // root is always compiled per-instance, because + // container attrs and props can be different every time. + var contextOptions = this._context && this._context.$options; + var rootLinker = compileRoot(el, options, contextOptions); + + // resolve slot distribution + resolveSlots(this, options._content); + + // compile and link the rest + var contentLinkFn; + var ctor = this.constructor; + // component compilation can be cached + // as long as it's not using inline-template + if (options._linkerCachable) { + contentLinkFn = ctor.linker; + if (!contentLinkFn) { + contentLinkFn = ctor.linker = compile(el, options); + } + } + + // link phase + // make sure to link root with prop scope! + var rootUnlinkFn = rootLinker(this, el, this._scope); + var contentUnlinkFn = contentLinkFn ? contentLinkFn(this, el) : compile(el, options)(this, el); + + // register composite unlink function + // to be called during instance destruction + this._unlinkFn = function () { + rootUnlinkFn(); + // passing destroying: true to avoid searching and + // splicing the directives + contentUnlinkFn(true); + }; + + // finally replace original + if (options.replace) { + replace(original, el); + } + + this._isCompiled = true; + this._callHook('compiled'); + }; + + /** + * Initialize instance element. Called in the public + * $mount() method. + * + * @param {Element} el + */ + + Vue.prototype._initElement = function (el) { + if (isFragment(el)) { + this._isFragment = true; + this.$el = this._fragmentStart = el.firstChild; + this._fragmentEnd = el.lastChild; + // set persisted text anchors to empty + if (this._fragmentStart.nodeType === 3) { + this._fragmentStart.data = this._fragmentEnd.data = ''; + } + this._fragment = el; + } else { + this.$el = el; + } + this.$el.__vue__ = this; + this._callHook('beforeCompile'); + }; + + /** + * Create and bind a directive to an element. + * + * @param {Object} descriptor - parsed directive descriptor + * @param {Node} node - target node + * @param {Vue} [host] - transclusion host component + * @param {Object} [scope] - v-for scope + * @param {Fragment} [frag] - owner fragment + */ + + Vue.prototype._bindDir = function (descriptor, node, host, scope, frag) { + this._directives.push(new Directive(descriptor, this, node, host, scope, frag)); + }; + + /** + * Teardown an instance, unobserves the data, unbind all the + * directives, turn off all the event listeners, etc. + * + * @param {Boolean} remove - whether to remove the DOM node. + * @param {Boolean} deferCleanup - if true, defer cleanup to + * be called later + */ + + Vue.prototype._destroy = function (remove, deferCleanup) { + if (this._isBeingDestroyed) { + if (!deferCleanup) { + this._cleanup(); + } + return; + } + + var destroyReady; + var pendingRemoval; + + var self = this; + // Cleanup should be called either synchronously or asynchronoysly as + // callback of this.$remove(), or if remove and deferCleanup are false. + // In any case it should be called after all other removing, unbinding and + // turning of is done + var cleanupIfPossible = function cleanupIfPossible() { + if (destroyReady && !pendingRemoval && !deferCleanup) { + self._cleanup(); + } + }; + + // remove DOM element + if (remove && this.$el) { + pendingRemoval = true; + this.$remove(function () { + pendingRemoval = false; + cleanupIfPossible(); + }); + } + + this._callHook('beforeDestroy'); + this._isBeingDestroyed = true; + var i; + // remove self from parent. only necessary + // if parent is not being destroyed as well. + var parent = this.$parent; + if (parent && !parent._isBeingDestroyed) { + parent.$children.$remove(this); + // unregister ref (remove: true) + this._updateRef(true); + } + // destroy all children. + i = this.$children.length; + while (i--) { + this.$children[i].$destroy(); + } + // teardown props + if (this._propsUnlinkFn) { + this._propsUnlinkFn(); + } + // teardown all directives. this also tearsdown all + // directive-owned watchers. + if (this._unlinkFn) { + this._unlinkFn(); + } + i = this._watchers.length; + while (i--) { + this._watchers[i].teardown(); + } + // remove reference to self on $el + if (this.$el) { + this.$el.__vue__ = null; + } + + destroyReady = true; + cleanupIfPossible(); + }; + + /** + * Clean up to ensure garbage collection. + * This is called after the leave transition if there + * is any. + */ + + Vue.prototype._cleanup = function () { + if (this._isDestroyed) { + return; + } + // remove self from owner fragment + // do it in cleanup so that we can call $destroy with + // defer right when a fragment is about to be removed. + if (this._frag) { + this._frag.children.$remove(this); + } + // remove reference from data ob + // frozen object may not have observer. + if (this._data && this._data.__ob__) { + this._data.__ob__.removeVm(this); + } + // Clean up references to private properties and other + // instances. preserve reference to _data so that proxy + // accessors still work. The only potential side effect + // here is that mutating the instance after it's destroyed + // may affect the state of other components that are still + // observing the same object, but that seems to be a + // reasonable responsibility for the user rather than + // always throwing an error on them. + this.$el = this.$parent = this.$root = this.$children = this._watchers = this._context = this._scope = this._directives = null; + // call the last hook... + this._isDestroyed = true; + this._callHook('destroyed'); + // turn off all instance listeners. + this.$off(); + }; + } + + function miscMixin (Vue) { + /** + * Apply a list of filter (descriptors) to a value. + * Using plain for loops here because this will be called in + * the getter of any watcher with filters so it is very + * performance sensitive. + * + * @param {*} value + * @param {*} [oldValue] + * @param {Array} filters + * @param {Boolean} write + * @return {*} + */ + + Vue.prototype._applyFilters = function (value, oldValue, filters, write) { + var filter, fn, args, arg, offset, i, l, j, k; + for (i = 0, l = filters.length; i < l; i++) { + filter = filters[write ? l - i - 1 : i]; + fn = resolveAsset(this.$options, 'filters', filter.name, true); + if (!fn) continue; + fn = write ? fn.write : fn.read || fn; + if (typeof fn !== 'function') continue; + args = write ? [value, oldValue] : [value]; + offset = write ? 2 : 1; + if (filter.args) { + for (j = 0, k = filter.args.length; j < k; j++) { + arg = filter.args[j]; + args[j + offset] = arg.dynamic ? this.$get(arg.value) : arg.value; + } + } + value = fn.apply(this, args); + } + return value; + }; + + /** + * Resolve a component, depending on whether the component + * is defined normally or using an async factory function. + * Resolves synchronously if already resolved, otherwise + * resolves asynchronously and caches the resolved + * constructor on the factory. + * + * @param {String|Function} value + * @param {Function} cb + */ + + Vue.prototype._resolveComponent = function (value, cb) { + var factory; + if (typeof value === 'function') { + factory = value; + } else { + factory = resolveAsset(this.$options, 'components', value, true); + } + /* istanbul ignore if */ + if (!factory) { + return; + } + // async component factory + if (!factory.options) { + if (factory.resolved) { + // cached + cb(factory.resolved); + } else if (factory.requested) { + // pool callbacks + factory.pendingCallbacks.push(cb); + } else { + factory.requested = true; + var cbs = factory.pendingCallbacks = [cb]; + factory.call(this, function resolve(res) { + if (isPlainObject(res)) { + res = Vue.extend(res); + } + // cache resolved + factory.resolved = res; + // invoke callbacks + for (var i = 0, l = cbs.length; i < l; i++) { + cbs[i](res); + } + }, function reject(reason) { + 'development' !== 'production' && warn('Failed to resolve async component' + (typeof value === 'string' ? ': ' + value : '') + '. ' + (reason ? '\nReason: ' + reason : '')); + }); + } + } else { + // normal component + cb(factory); + } + }; + } + + var filterRE$1 = /[^|]\|[^|]/; + + function dataAPI (Vue) { + /** + * Get the value from an expression on this vm. + * + * @param {String} exp + * @param {Boolean} [asStatement] + * @return {*} + */ + + Vue.prototype.$get = function (exp, asStatement) { + var res = parseExpression(exp); + if (res) { + if (asStatement) { + var self = this; + return function statementHandler() { + self.$arguments = toArray(arguments); + var result = res.get.call(self, self); + self.$arguments = null; + return result; + }; + } else { + try { + return res.get.call(this, this); + } catch (e) {} + } + } + }; + + /** + * Set the value from an expression on this vm. + * The expression must be a valid left-hand + * expression in an assignment. + * + * @param {String} exp + * @param {*} val + */ + + Vue.prototype.$set = function (exp, val) { + var res = parseExpression(exp, true); + if (res && res.set) { + res.set.call(this, this, val); + } + }; + + /** + * Delete a property on the VM + * + * @param {String} key + */ + + Vue.prototype.$delete = function (key) { + del(this._data, key); + }; + + /** + * Watch an expression, trigger callback when its + * value changes. + * + * @param {String|Function} expOrFn + * @param {Function} cb + * @param {Object} [options] + * - {Boolean} deep + * - {Boolean} immediate + * @return {Function} - unwatchFn + */ + + Vue.prototype.$watch = function (expOrFn, cb, options) { + var vm = this; + var parsed; + if (typeof expOrFn === 'string') { + parsed = parseDirective(expOrFn); + expOrFn = parsed.expression; + } + var watcher = new Watcher(vm, expOrFn, cb, { + deep: options && options.deep, + sync: options && options.sync, + filters: parsed && parsed.filters, + user: !options || options.user !== false + }); + if (options && options.immediate) { + cb.call(vm, watcher.value); + } + return function unwatchFn() { + watcher.teardown(); + }; + }; + + /** + * Evaluate a text directive, including filters. + * + * @param {String} text + * @param {Boolean} [asStatement] + * @return {String} + */ + + Vue.prototype.$eval = function (text, asStatement) { + // check for filters. + if (filterRE$1.test(text)) { + var dir = parseDirective(text); + // the filter regex check might give false positive + // for pipes inside strings, so it's possible that + // we don't get any filters here + var val = this.$get(dir.expression, asStatement); + return dir.filters ? this._applyFilters(val, null, dir.filters) : val; + } else { + // no filter + return this.$get(text, asStatement); + } + }; + + /** + * Interpolate a piece of template text. + * + * @param {String} text + * @return {String} + */ + + Vue.prototype.$interpolate = function (text) { + var tokens = parseText(text); + var vm = this; + if (tokens) { + if (tokens.length === 1) { + return vm.$eval(tokens[0].value) + ''; + } else { + return tokens.map(function (token) { + return token.tag ? vm.$eval(token.value) : token.value; + }).join(''); + } + } else { + return text; + } + }; + + /** + * Log instance data as a plain JS object + * so that it is easier to inspect in console. + * This method assumes console is available. + * + * @param {String} [path] + */ + + Vue.prototype.$log = function (path) { + var data = path ? getPath(this._data, path) : this._data; + if (data) { + data = clean(data); + } + // include computed fields + if (!path) { + var key; + for (key in this.$options.computed) { + data[key] = clean(this[key]); + } + if (this._props) { + for (key in this._props) { + data[key] = clean(this[key]); + } + } + } + console.log(data); + }; + + /** + * "clean" a getter/setter converted object into a plain + * object copy. + * + * @param {Object} - obj + * @return {Object} + */ + + function clean(obj) { + return JSON.parse(JSON.stringify(obj)); + } + } + + function domAPI (Vue) { + /** + * Convenience on-instance nextTick. The callback is + * auto-bound to the instance, and this avoids component + * modules having to rely on the global Vue. + * + * @param {Function} fn + */ + + Vue.prototype.$nextTick = function (fn) { + nextTick(fn, this); + }; + + /** + * Append instance to target + * + * @param {Node} target + * @param {Function} [cb] + * @param {Boolean} [withTransition] - defaults to true + */ + + Vue.prototype.$appendTo = function (target, cb, withTransition) { + return insert(this, target, cb, withTransition, append, appendWithTransition); + }; + + /** + * Prepend instance to target + * + * @param {Node} target + * @param {Function} [cb] + * @param {Boolean} [withTransition] - defaults to true + */ + + Vue.prototype.$prependTo = function (target, cb, withTransition) { + target = query(target); + if (target.hasChildNodes()) { + this.$before(target.firstChild, cb, withTransition); + } else { + this.$appendTo(target, cb, withTransition); + } + return this; + }; + + /** + * Insert instance before target + * + * @param {Node} target + * @param {Function} [cb] + * @param {Boolean} [withTransition] - defaults to true + */ + + Vue.prototype.$before = function (target, cb, withTransition) { + return insert(this, target, cb, withTransition, beforeWithCb, beforeWithTransition); + }; + + /** + * Insert instance after target + * + * @param {Node} target + * @param {Function} [cb] + * @param {Boolean} [withTransition] - defaults to true + */ + + Vue.prototype.$after = function (target, cb, withTransition) { + target = query(target); + if (target.nextSibling) { + this.$before(target.nextSibling, cb, withTransition); + } else { + this.$appendTo(target.parentNode, cb, withTransition); + } + return this; + }; + + /** + * Remove instance from DOM + * + * @param {Function} [cb] + * @param {Boolean} [withTransition] - defaults to true + */ + + Vue.prototype.$remove = function (cb, withTransition) { + if (!this.$el.parentNode) { + return cb && cb(); + } + var inDocument = this._isAttached && inDoc(this.$el); + // if we are not in document, no need to check + // for transitions + if (!inDocument) withTransition = false; + var self = this; + var realCb = function realCb() { + if (inDocument) self._callHook('detached'); + if (cb) cb(); + }; + if (this._isFragment) { + removeNodeRange(this._fragmentStart, this._fragmentEnd, this, this._fragment, realCb); + } else { + var op = withTransition === false ? removeWithCb : removeWithTransition; + op(this.$el, this, realCb); + } + return this; + }; + + /** + * Shared DOM insertion function. + * + * @param {Vue} vm + * @param {Element} target + * @param {Function} [cb] + * @param {Boolean} [withTransition] + * @param {Function} op1 - op for non-transition insert + * @param {Function} op2 - op for transition insert + * @return vm + */ + + function insert(vm, target, cb, withTransition, op1, op2) { + target = query(target); + var targetIsDetached = !inDoc(target); + var op = withTransition === false || targetIsDetached ? op1 : op2; + var shouldCallHook = !targetIsDetached && !vm._isAttached && !inDoc(vm.$el); + if (vm._isFragment) { + mapNodeRange(vm._fragmentStart, vm._fragmentEnd, function (node) { + op(node, target, vm); + }); + cb && cb(); + } else { + op(vm.$el, target, vm, cb); + } + if (shouldCallHook) { + vm._callHook('attached'); + } + return vm; + } + + /** + * Check for selectors + * + * @param {String|Element} el + */ + + function query(el) { + return typeof el === 'string' ? document.querySelector(el) : el; + } + + /** + * Append operation that takes a callback. + * + * @param {Node} el + * @param {Node} target + * @param {Vue} vm - unused + * @param {Function} [cb] + */ + + function append(el, target, vm, cb) { + target.appendChild(el); + if (cb) cb(); + } + + /** + * InsertBefore operation that takes a callback. + * + * @param {Node} el + * @param {Node} target + * @param {Vue} vm - unused + * @param {Function} [cb] + */ + + function beforeWithCb(el, target, vm, cb) { + before(el, target); + if (cb) cb(); + } + + /** + * Remove operation that takes a callback. + * + * @param {Node} el + * @param {Vue} vm - unused + * @param {Function} [cb] + */ + + function removeWithCb(el, vm, cb) { + remove(el); + if (cb) cb(); + } + } + + function eventsAPI (Vue) { + /** + * Listen on the given `event` with `fn`. + * + * @param {String} event + * @param {Function} fn + */ + + Vue.prototype.$on = function (event, fn) { + (this._events[event] || (this._events[event] = [])).push(fn); + modifyListenerCount(this, event, 1); + return this; + }; + + /** + * Adds an `event` listener that will be invoked a single + * time then automatically removed. + * + * @param {String} event + * @param {Function} fn + */ + + Vue.prototype.$once = function (event, fn) { + var self = this; + function on() { + self.$off(event, on); + fn.apply(this, arguments); + } + on.fn = fn; + this.$on(event, on); + return this; + }; + + /** + * Remove the given callback for `event` or all + * registered callbacks. + * + * @param {String} event + * @param {Function} fn + */ + + Vue.prototype.$off = function (event, fn) { + var cbs; + // all + if (!arguments.length) { + if (this.$parent) { + for (event in this._events) { + cbs = this._events[event]; + if (cbs) { + modifyListenerCount(this, event, -cbs.length); + } + } + } + this._events = {}; + return this; + } + // specific event + cbs = this._events[event]; + if (!cbs) { + return this; + } + if (arguments.length === 1) { + modifyListenerCount(this, event, -cbs.length); + this._events[event] = null; + return this; + } + // specific handler + var cb; + var i = cbs.length; + while (i--) { + cb = cbs[i]; + if (cb === fn || cb.fn === fn) { + modifyListenerCount(this, event, -1); + cbs.splice(i, 1); + break; + } + } + return this; + }; + + /** + * Trigger an event on self. + * + * @param {String|Object} event + * @return {Boolean} shouldPropagate + */ + + Vue.prototype.$emit = function (event) { + var isSource = typeof event === 'string'; + event = isSource ? event : event.name; + var cbs = this._events[event]; + var shouldPropagate = isSource || !cbs; + if (cbs) { + cbs = cbs.length > 1 ? toArray(cbs) : cbs; + // this is a somewhat hacky solution to the question raised + // in #2102: for an inline component listener like <comp @test="doThis">, + // the propagation handling is somewhat broken. Therefore we + // need to treat these inline callbacks differently. + var hasParentCbs = isSource && cbs.some(function (cb) { + return cb._fromParent; + }); + if (hasParentCbs) { + shouldPropagate = false; + } + var args = toArray(arguments, 1); + for (var i = 0, l = cbs.length; i < l; i++) { + var cb = cbs[i]; + var res = cb.apply(this, args); + if (res === true && (!hasParentCbs || cb._fromParent)) { + shouldPropagate = true; + } + } + } + return shouldPropagate; + }; + + /** + * Recursively broadcast an event to all children instances. + * + * @param {String|Object} event + * @param {...*} additional arguments + */ + + Vue.prototype.$broadcast = function (event) { + var isSource = typeof event === 'string'; + event = isSource ? event : event.name; + // if no child has registered for this event, + // then there's no need to broadcast. + if (!this._eventsCount[event]) return; + var children = this.$children; + var args = toArray(arguments); + if (isSource) { + // use object event to indicate non-source emit + // on children + args[0] = { name: event, source: this }; + } + for (var i = 0, l = children.length; i < l; i++) { + var child = children[i]; + var shouldPropagate = child.$emit.apply(child, args); + if (shouldPropagate) { + child.$broadcast.apply(child, args); + } + } + return this; + }; + + /** + * Recursively propagate an event up the parent chain. + * + * @param {String} event + * @param {...*} additional arguments + */ + + Vue.prototype.$dispatch = function (event) { + var shouldPropagate = this.$emit.apply(this, arguments); + if (!shouldPropagate) return; + var parent = this.$parent; + var args = toArray(arguments); + // use object event to indicate non-source emit + // on parents + args[0] = { name: event, source: this }; + while (parent) { + shouldPropagate = parent.$emit.apply(parent, args); + parent = shouldPropagate ? parent.$parent : null; + } + return this; + }; + + /** + * Modify the listener counts on all parents. + * This bookkeeping allows $broadcast to return early when + * no child has listened to a certain event. + * + * @param {Vue} vm + * @param {String} event + * @param {Number} count + */ + + var hookRE = /^hook:/; + function modifyListenerCount(vm, event, count) { + var parent = vm.$parent; + // hooks do not get broadcasted so no need + // to do bookkeeping for them + if (!parent || !count || hookRE.test(event)) return; + while (parent) { + parent._eventsCount[event] = (parent._eventsCount[event] || 0) + count; + parent = parent.$parent; + } + } + } + + function lifecycleAPI (Vue) { + /** + * Set instance target element and kick off the compilation + * process. The passed in `el` can be a selector string, an + * existing Element, or a DocumentFragment (for block + * instances). + * + * @param {Element|DocumentFragment|string} el + * @public + */ + + Vue.prototype.$mount = function (el) { + if (this._isCompiled) { + 'development' !== 'production' && warn('$mount() should be called only once.', this); + return; + } + el = query(el); + if (!el) { + el = document.createElement('div'); + } + this._compile(el); + this._initDOMHooks(); + if (inDoc(this.$el)) { + this._callHook('attached'); + ready.call(this); + } else { + this.$once('hook:attached', ready); + } + return this; + }; + + /** + * Mark an instance as ready. + */ + + function ready() { + this._isAttached = true; + this._isReady = true; + this._callHook('ready'); + } + + /** + * Teardown the instance, simply delegate to the internal + * _destroy. + * + * @param {Boolean} remove + * @param {Boolean} deferCleanup + */ + + Vue.prototype.$destroy = function (remove, deferCleanup) { + this._destroy(remove, deferCleanup); + }; + + /** + * Partially compile a piece of DOM and return a + * decompile function. + * + * @param {Element|DocumentFragment} el + * @param {Vue} [host] + * @param {Object} [scope] + * @param {Fragment} [frag] + * @return {Function} + */ + + Vue.prototype.$compile = function (el, host, scope, frag) { + return compile(el, this.$options, true)(this, el, host, scope, frag); + }; + } + + /** + * The exposed Vue constructor. + * + * API conventions: + * - public API methods/properties are prefixed with `$` + * - internal methods/properties are prefixed with `_` + * - non-prefixed properties are assumed to be proxied user + * data. + * + * @constructor + * @param {Object} [options] + * @public + */ + + function Vue(options) { + this._init(options); + } + + // install internals + initMixin(Vue); + stateMixin(Vue); + eventsMixin(Vue); + lifecycleMixin(Vue); + miscMixin(Vue); + + // install instance APIs + dataAPI(Vue); + domAPI(Vue); + eventsAPI(Vue); + lifecycleAPI(Vue); + + var slot = { + + priority: SLOT, + params: ['name'], + + bind: function bind() { + // this was resolved during component transclusion + var name = this.params.name || 'default'; + var content = this.vm._slotContents && this.vm._slotContents[name]; + if (!content || !content.hasChildNodes()) { + this.fallback(); + } else { + this.compile(content.cloneNode(true), this.vm._context, this.vm); + } + }, + + compile: function compile(content, context, host) { + if (content && context) { + if (this.el.hasChildNodes() && content.childNodes.length === 1 && content.childNodes[0].nodeType === 1 && content.childNodes[0].hasAttribute('v-if')) { + // if the inserted slot has v-if + // inject fallback content as the v-else + var elseBlock = document.createElement('template'); + elseBlock.setAttribute('v-else', ''); + elseBlock.innerHTML = this.el.innerHTML; + // the else block should be compiled in child scope + elseBlock._context = this.vm; + content.appendChild(elseBlock); + } + var scope = host ? host._scope : this._scope; + this.unlink = context.$compile(content, host, scope, this._frag); + } + if (content) { + replace(this.el, content); + } else { + remove(this.el); + } + }, + + fallback: function fallback() { + this.compile(extractContent(this.el, true), this.vm); + }, + + unbind: function unbind() { + if (this.unlink) { + this.unlink(); + } + } + }; + + var partial = { + + priority: PARTIAL, + + params: ['name'], + + // watch changes to name for dynamic partials + paramWatchers: { + name: function name(value) { + vIf.remove.call(this); + if (value) { + this.insert(value); + } + } + }, + + bind: function bind() { + this.anchor = createAnchor('v-partial'); + replace(this.el, this.anchor); + this.insert(this.params.name); + }, + + insert: function insert(id) { + var partial = resolveAsset(this.vm.$options, 'partials', id, true); + if (partial) { + this.factory = new FragmentFactory(this.vm, partial); + vIf.insert.call(this); + } + }, + + unbind: function unbind() { + if (this.frag) { + this.frag.destroy(); + } + } + }; + + var elementDirectives = { + slot: slot, + partial: partial + }; + + var convertArray = vFor._postProcess; + + /** + * Limit filter for arrays + * + * @param {Number} n + * @param {Number} offset (Decimal expected) + */ + + function limitBy(arr, n, offset) { + offset = offset ? parseInt(offset, 10) : 0; + n = toNumber(n); + return typeof n === 'number' ? arr.slice(offset, offset + n) : arr; + } + + /** + * Filter filter for arrays + * + * @param {String} search + * @param {String} [delimiter] + * @param {String} ...dataKeys + */ + + function filterBy(arr, search, delimiter) { + arr = convertArray(arr); + if (search == null) { + return arr; + } + if (typeof search === 'function') { + return arr.filter(search); + } + // cast to lowercase string + search = ('' + search).toLowerCase(); + // allow optional `in` delimiter + // because why not + var n = delimiter === 'in' ? 3 : 2; + // extract and flatten keys + var keys = Array.prototype.concat.apply([], toArray(arguments, n)); + var res = []; + var item, key, val, j; + for (var i = 0, l = arr.length; i < l; i++) { + item = arr[i]; + val = item && item.$value || item; + j = keys.length; + if (j) { + while (j--) { + key = keys[j]; + if (key === '$key' && contains(item.$key, search) || contains(getPath(val, key), search)) { + res.push(item); + break; + } + } + } else if (contains(item, search)) { + res.push(item); + } + } + return res; + } + + /** + * Filter filter for arrays + * + * @param {String|Array<String>|Function} ...sortKeys + * @param {Number} [order] + */ + + function orderBy(arr) { + var comparator = null; + var sortKeys = undefined; + arr = convertArray(arr); + + // determine order (last argument) + var args = toArray(arguments, 1); + var order = args[args.length - 1]; + if (typeof order === 'number') { + order = order < 0 ? -1 : 1; + args = args.length > 1 ? args.slice(0, -1) : args; + } else { + order = 1; + } + + // determine sortKeys & comparator + var firstArg = args[0]; + if (!firstArg) { + return arr; + } else if (typeof firstArg === 'function') { + // custom comparator + comparator = function (a, b) { + return firstArg(a, b) * order; + }; + } else { + // string keys. flatten first + sortKeys = Array.prototype.concat.apply([], args); + comparator = function (a, b, i) { + i = i || 0; + return i >= sortKeys.length - 1 ? baseCompare(a, b, i) : baseCompare(a, b, i) || comparator(a, b, i + 1); + }; + } + + function baseCompare(a, b, sortKeyIndex) { + var sortKey = sortKeys[sortKeyIndex]; + if (sortKey) { + if (sortKey !== '$key') { + if (isObject(a) && '$value' in a) a = a.$value; + if (isObject(b) && '$value' in b) b = b.$value; + } + a = isObject(a) ? getPath(a, sortKey) : a; + b = isObject(b) ? getPath(b, sortKey) : b; + } + return a === b ? 0 : a > b ? order : -order; + } + + // sort on a copy to avoid mutating original array + return arr.slice().sort(comparator); + } + + /** + * String contain helper + * + * @param {*} val + * @param {String} search + */ + + function contains(val, search) { + var i; + if (isPlainObject(val)) { + var keys = Object.keys(val); + i = keys.length; + while (i--) { + if (contains(val[keys[i]], search)) { + return true; + } + } + } else if (isArray(val)) { + i = val.length; + while (i--) { + if (contains(val[i], search)) { + return true; + } + } + } else if (val != null) { + return val.toString().toLowerCase().indexOf(search) > -1; + } + } + + var digitsRE = /(\d{3})(?=\d)/g; + + // asset collections must be a plain object. + var filters = { + + orderBy: orderBy, + filterBy: filterBy, + limitBy: limitBy, + + /** + * Stringify value. + * + * @param {Number} indent + */ + + json: { + read: function read(value, indent) { + return typeof value === 'string' ? value : JSON.stringify(value, null, arguments.length > 1 ? indent : 2); + }, + write: function write(value) { + try { + return JSON.parse(value); + } catch (e) { + return value; + } + } + }, + + /** + * 'abc' => 'Abc' + */ + + capitalize: function capitalize(value) { + if (!value && value !== 0) return ''; + value = value.toString(); + return value.charAt(0).toUpperCase() + value.slice(1); + }, + + /** + * 'abc' => 'ABC' + */ + + uppercase: function uppercase(value) { + return value || value === 0 ? value.toString().toUpperCase() : ''; + }, + + /** + * 'AbC' => 'abc' + */ + + lowercase: function lowercase(value) { + return value || value === 0 ? value.toString().toLowerCase() : ''; + }, + + /** + * 12345 => $12,345.00 + * + * @param {String} sign + * @param {Number} decimals Decimal places + */ + + currency: function currency(value, _currency, decimals) { + value = parseFloat(value); + if (!isFinite(value) || !value && value !== 0) return ''; + _currency = _currency != null ? _currency : '$'; + decimals = decimals != null ? decimals : 2; + var stringified = Math.abs(value).toFixed(decimals); + var _int = decimals ? stringified.slice(0, -1 - decimals) : stringified; + var i = _int.length % 3; + var head = i > 0 ? _int.slice(0, i) + (_int.length > 3 ? ',' : '') : ''; + var _float = decimals ? stringified.slice(-1 - decimals) : ''; + var sign = value < 0 ? '-' : ''; + return sign + _currency + head + _int.slice(i).replace(digitsRE, '$1,') + _float; + }, + + /** + * 'item' => 'items' + * + * @params + * an array of strings corresponding to + * the single, double, triple ... forms of the word to + * be pluralized. When the number to be pluralized + * exceeds the length of the args, it will use the last + * entry in the array. + * + * e.g. ['single', 'double', 'triple', 'multiple'] + */ + + pluralize: function pluralize(value) { + var args = toArray(arguments, 1); + var length = args.length; + if (length > 1) { + var index = value % 10 - 1; + return index in args ? args[index] : args[length - 1]; + } else { + return args[0] + (value === 1 ? '' : 's'); + } + }, + + /** + * Debounce a handler function. + * + * @param {Function} handler + * @param {Number} delay = 300 + * @return {Function} + */ + + debounce: function debounce(handler, delay) { + if (!handler) return; + if (!delay) { + delay = 300; + } + return _debounce(handler, delay); + } + }; + + function installGlobalAPI (Vue) { + /** + * Vue and every constructor that extends Vue has an + * associated options object, which can be accessed during + * compilation steps as `this.constructor.options`. + * + * These can be seen as the default options of every + * Vue instance. + */ + + Vue.options = { + directives: directives, + elementDirectives: elementDirectives, + filters: filters, + transitions: {}, + components: {}, + partials: {}, + replace: true + }; + + /** + * Expose useful internals + */ + + Vue.util = util; + Vue.config = config; + Vue.set = set; + Vue['delete'] = del; + Vue.nextTick = nextTick; + + /** + * The following are exposed for advanced usage / plugins + */ + + Vue.compiler = compiler; + Vue.FragmentFactory = FragmentFactory; + Vue.internalDirectives = internalDirectives; + Vue.parsers = { + path: path, + text: text, + template: template, + directive: directive, + expression: expression + }; + + /** + * Each instance constructor, including Vue, has a unique + * cid. This enables us to create wrapped "child + * constructors" for prototypal inheritance and cache them. + */ + + Vue.cid = 0; + var cid = 1; + + /** + * Class inheritance + * + * @param {Object} extendOptions + */ + + Vue.extend = function (extendOptions) { + extendOptions = extendOptions || {}; + var Super = this; + var isFirstExtend = Super.cid === 0; + if (isFirstExtend && extendOptions._Ctor) { + return extendOptions._Ctor; + } + var name = extendOptions.name || Super.options.name; + if ('development' !== 'production') { + if (!/^[a-zA-Z][\w-]*$/.test(name)) { + warn('Invalid component name: "' + name + '". Component names ' + 'can only contain alphanumeric characaters and the hyphen.'); + name = null; + } + } + var Sub = createClass(name || 'VueComponent'); + Sub.prototype = Object.create(Super.prototype); + Sub.prototype.constructor = Sub; + Sub.cid = cid++; + Sub.options = mergeOptions(Super.options, extendOptions); + Sub['super'] = Super; + // allow further extension + Sub.extend = Super.extend; + // create asset registers, so extended classes + // can have their private assets too. + config._assetTypes.forEach(function (type) { + Sub[type] = Super[type]; + }); + // enable recursive self-lookup + if (name) { + Sub.options.components[name] = Sub; + } + // cache constructor + if (isFirstExtend) { + extendOptions._Ctor = Sub; + } + return Sub; + }; + + /** + * A function that returns a sub-class constructor with the + * given name. This gives us much nicer output when + * logging instances in the console. + * + * @param {String} name + * @return {Function} + */ + + function createClass(name) { + /* eslint-disable no-new-func */ + return new Function('return function ' + classify(name) + ' (options) { this._init(options) }')(); + /* eslint-enable no-new-func */ + } + + /** + * Plugin system + * + * @param {Object} plugin + */ + + Vue.use = function (plugin) { + /* istanbul ignore if */ + if (plugin.installed) { + return; + } + // additional parameters + var args = toArray(arguments, 1); + args.unshift(this); + if (typeof plugin.install === 'function') { + plugin.install.apply(plugin, args); + } else { + plugin.apply(null, args); + } + plugin.installed = true; + return this; + }; + + /** + * Apply a global mixin by merging it into the default + * options. + */ + + Vue.mixin = function (mixin) { + Vue.options = mergeOptions(Vue.options, mixin); + }; + + /** + * Create asset registration methods with the following + * signature: + * + * @param {String} id + * @param {*} definition + */ + + config._assetTypes.forEach(function (type) { + Vue[type] = function (id, definition) { + if (!definition) { + return this.options[type + 's'][id]; + } else { + /* istanbul ignore if */ + if ('development' !== 'production') { + if (type === 'component' && (commonTagRE.test(id) || reservedTagRE.test(id))) { + warn('Do not use built-in or reserved HTML elements as component ' + 'id: ' + id); + } + } + if (type === 'component' && isPlainObject(definition)) { + if (!definition.name) { + definition.name = id; + } + definition = Vue.extend(definition); + } + this.options[type + 's'][id] = definition; + return definition; + } + }; + }); + + // expose internal transition API + extend(Vue.transition, transition); + } + + installGlobalAPI(Vue); + + Vue.version = '1.0.26'; + + // devtools global hook + /* istanbul ignore next */ + setTimeout(function () { + if (config.devtools) { + if (devtools) { + devtools.emit('init', Vue); + } else if ('development' !== 'production' && inBrowser && /Chrome\/\d+/.test(window.navigator.userAgent)) { + console.log('Download the Vue Devtools for a better development experience:\n' + 'https://github.com/vuejs/vue-devtools'); + } + } + }, 0); + + return Vue; + +}));
\ No newline at end of file diff --git a/vendor/assets/javascripts/vue.js.erb b/vendor/assets/javascripts/vue.js.erb new file mode 100644 index 00000000000..008beb10f4d --- /dev/null +++ b/vendor/assets/javascripts/vue.js.erb @@ -0,0 +1,2 @@ +<% type = Rails.env.development? ? 'full' : 'min' %> +<%= File.read(Rails.root.join("vendor/assets/javascripts/vue.#{type}.js")) %> diff --git a/vendor/assets/javascripts/vue.min.js b/vendor/assets/javascripts/vue.min.js new file mode 100644 index 00000000000..2c9a8a0e117 --- /dev/null +++ b/vendor/assets/javascripts/vue.min.js @@ -0,0 +1,9 @@ +/*! + * Vue.js v1.0.26 + * (c) 2016 Evan You + * Released under the MIT License. + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.Vue=e()}(this,function(){"use strict";function t(e,n,r){if(i(e,n))return void(e[n]=r);if(e._isVue)return void t(e._data,n,r);var s=e.__ob__;if(!s)return void(e[n]=r);if(s.convert(n,r),s.dep.notify(),s.vms)for(var o=s.vms.length;o--;){var a=s.vms[o];a._proxy(n),a._digest()}return r}function e(t,e){if(i(t,e)){delete t[e];var n=t.__ob__;if(!n)return void(t._isVue&&(delete t._data[e],t._digest()));if(n.dep.notify(),n.vms)for(var r=n.vms.length;r--;){var s=n.vms[r];s._unproxy(e),s._digest()}}}function i(t,e){return Oi.call(t,e)}function n(t){return Ti.test(t)}function r(t){var e=(t+"").charCodeAt(0);return 36===e||95===e}function s(t){return null==t?"":t.toString()}function o(t){if("string"!=typeof t)return t;var e=Number(t);return isNaN(e)?t:e}function a(t){return"true"===t?!0:"false"===t?!1:t}function h(t){var e=t.charCodeAt(0),i=t.charCodeAt(t.length-1);return e!==i||34!==e&&39!==e?t:t.slice(1,-1)}function l(t){return t.replace(Ni,c)}function c(t,e){return e?e.toUpperCase():""}function u(t){return t.replace(ji,"$1-$2").toLowerCase()}function f(t){return t.replace(Ei,c)}function p(t,e){return function(i){var n=arguments.length;return n?n>1?t.apply(e,arguments):t.call(e,i):t.call(e)}}function d(t,e){e=e||0;for(var i=t.length-e,n=new Array(i);i--;)n[i]=t[i+e];return n}function v(t,e){for(var i=Object.keys(e),n=i.length;n--;)t[i[n]]=e[i[n]];return t}function m(t){return null!==t&&"object"==typeof t}function g(t){return Si.call(t)===Fi}function _(t,e,i,n){Object.defineProperty(t,e,{value:i,enumerable:!!n,writable:!0,configurable:!0})}function y(t,e){var i,n,r,s,o,a=function h(){var a=Date.now()-s;e>a&&a>=0?i=setTimeout(h,e-a):(i=null,o=t.apply(r,n),i||(r=n=null))};return function(){return r=this,n=arguments,s=Date.now(),i||(i=setTimeout(a,e)),o}}function b(t,e){for(var i=t.length;i--;)if(t[i]===e)return i;return-1}function w(t){var e=function i(){return i.cancelled?void 0:t.apply(this,arguments)};return e.cancel=function(){e.cancelled=!0},e}function C(t,e){return t==e||(m(t)&&m(e)?JSON.stringify(t)===JSON.stringify(e):!1)}function $(t){this.size=0,this.limit=t,this.head=this.tail=void 0,this._keymap=Object.create(null)}function k(){var t,e=en.slice(hn,on).trim();if(e){t={};var i=e.match(vn);t.name=i[0],i.length>1&&(t.args=i.slice(1).map(x))}t&&(nn.filters=nn.filters||[]).push(t),hn=on+1}function x(t){if(mn.test(t))return{value:o(t),dynamic:!1};var e=h(t),i=e===t;return{value:i?t:e,dynamic:i}}function A(t){var e=dn.get(t);if(e)return e;for(en=t,ln=cn=!1,un=fn=pn=0,hn=0,nn={},on=0,an=en.length;an>on;on++)if(sn=rn,rn=en.charCodeAt(on),ln)39===rn&&92!==sn&&(ln=!ln);else if(cn)34===rn&&92!==sn&&(cn=!cn);else if(124===rn&&124!==en.charCodeAt(on+1)&&124!==en.charCodeAt(on-1))null==nn.expression?(hn=on+1,nn.expression=en.slice(0,on).trim()):k();else switch(rn){case 34:cn=!0;break;case 39:ln=!0;break;case 40:pn++;break;case 41:pn--;break;case 91:fn++;break;case 93:fn--;break;case 123:un++;break;case 125:un--}return null==nn.expression?nn.expression=en.slice(0,on).trim():0!==hn&&k(),dn.put(t,nn),nn}function O(t){return t.replace(_n,"\\$&")}function T(){var t=O(An.delimiters[0]),e=O(An.delimiters[1]),i=O(An.unsafeDelimiters[0]),n=O(An.unsafeDelimiters[1]);bn=new RegExp(i+"((?:.|\\n)+?)"+n+"|"+t+"((?:.|\\n)+?)"+e,"g"),wn=new RegExp("^"+i+"((?:.|\\n)+?)"+n+"$"),yn=new $(1e3)}function N(t){yn||T();var e=yn.get(t);if(e)return e;if(!bn.test(t))return null;for(var i,n,r,s,o,a,h=[],l=bn.lastIndex=0;i=bn.exec(t);)n=i.index,n>l&&h.push({value:t.slice(l,n)}),r=wn.test(i[0]),s=r?i[1]:i[2],o=s.charCodeAt(0),a=42===o,s=a?s.slice(1):s,h.push({tag:!0,value:s.trim(),html:r,oneTime:a}),l=n+i[0].length;return l<t.length&&h.push({value:t.slice(l)}),yn.put(t,h),h}function j(t,e){return t.length>1?t.map(function(t){return E(t,e)}).join("+"):E(t[0],e,!0)}function E(t,e,i){return t.tag?t.oneTime&&e?'"'+e.$eval(t.value)+'"':S(t.value,i):'"'+t.value+'"'}function S(t,e){if(Cn.test(t)){var i=A(t);return i.filters?"this._applyFilters("+i.expression+",null,"+JSON.stringify(i.filters)+",false)":"("+t+")"}return e?t:"("+t+")"}function F(t,e,i,n){R(t,1,function(){e.appendChild(t)},i,n)}function D(t,e,i,n){R(t,1,function(){B(t,e)},i,n)}function P(t,e,i){R(t,-1,function(){z(t)},e,i)}function R(t,e,i,n,r){var s=t.__v_trans;if(!s||!s.hooks&&!qi||!n._isCompiled||n.$parent&&!n.$parent._isCompiled)return i(),void(r&&r());var o=e>0?"enter":"leave";s[o](i,r)}function L(t){if("string"==typeof t){t=document.querySelector(t)}return t}function H(t){if(!t)return!1;var e=t.ownerDocument.documentElement,i=t.parentNode;return e===t||e===i||!(!i||1!==i.nodeType||!e.contains(i))}function I(t,e){var i=t.getAttribute(e);return null!==i&&t.removeAttribute(e),i}function M(t,e){var i=I(t,":"+e);return null===i&&(i=I(t,"v-bind:"+e)),i}function V(t,e){return t.hasAttribute(e)||t.hasAttribute(":"+e)||t.hasAttribute("v-bind:"+e)}function B(t,e){e.parentNode.insertBefore(t,e)}function W(t,e){e.nextSibling?B(t,e.nextSibling):e.parentNode.appendChild(t)}function z(t){t.parentNode.removeChild(t)}function U(t,e){e.firstChild?B(t,e.firstChild):e.appendChild(t)}function J(t,e){var i=t.parentNode;i&&i.replaceChild(e,t)}function q(t,e,i,n){t.addEventListener(e,i,n)}function Q(t,e,i){t.removeEventListener(e,i)}function G(t){var e=t.className;return"object"==typeof e&&(e=e.baseVal||""),e}function Z(t,e){Mi&&!/svg$/.test(t.namespaceURI)?t.className=e:t.setAttribute("class",e)}function X(t,e){if(t.classList)t.classList.add(e);else{var i=" "+G(t)+" ";i.indexOf(" "+e+" ")<0&&Z(t,(i+e).trim())}}function Y(t,e){if(t.classList)t.classList.remove(e);else{for(var i=" "+G(t)+" ",n=" "+e+" ";i.indexOf(n)>=0;)i=i.replace(n," ");Z(t,i.trim())}t.className||t.removeAttribute("class")}function K(t,e){var i,n;if(it(t)&&at(t.content)&&(t=t.content),t.hasChildNodes())for(tt(t),n=e?document.createDocumentFragment():document.createElement("div");i=t.firstChild;)n.appendChild(i);return n}function tt(t){for(var e;e=t.firstChild,et(e);)t.removeChild(e);for(;e=t.lastChild,et(e);)t.removeChild(e)}function et(t){return t&&(3===t.nodeType&&!t.data.trim()||8===t.nodeType)}function it(t){return t.tagName&&"template"===t.tagName.toLowerCase()}function nt(t,e){var i=An.debug?document.createComment(t):document.createTextNode(e?" ":"");return i.__v_anchor=!0,i}function rt(t){if(t.hasAttributes())for(var e=t.attributes,i=0,n=e.length;n>i;i++){var r=e[i].name;if(Nn.test(r))return l(r.replace(Nn,""))}}function st(t,e,i){for(var n;t!==e;)n=t.nextSibling,i(t),t=n;i(e)}function ot(t,e,i,n,r){function s(){if(a++,o&&a>=h.length){for(var t=0;t<h.length;t++)n.appendChild(h[t]);r&&r()}}var o=!1,a=0,h=[];st(t,e,function(t){t===e&&(o=!0),h.push(t),P(t,i,s)})}function at(t){return t&&11===t.nodeType}function ht(t){if(t.outerHTML)return t.outerHTML;var e=document.createElement("div");return e.appendChild(t.cloneNode(!0)),e.innerHTML}function lt(t,e){var i=t.tagName.toLowerCase(),n=t.hasAttributes();if(jn.test(i)||En.test(i)){if(n)return ct(t,e)}else{if(gt(e,"components",i))return{id:i};var r=n&&ct(t,e);if(r)return r}}function ct(t,e){var i=t.getAttribute("is");if(null!=i){if(gt(e,"components",i))return t.removeAttribute("is"),{id:i}}else if(i=M(t,"is"),null!=i)return{id:i,dynamic:!0}}function ut(e,n){var r,s,o;for(r in n)s=e[r],o=n[r],i(e,r)?m(s)&&m(o)&&ut(s,o):t(e,r,o);return e}function ft(t,e){var i=Object.create(t||null);return e?v(i,vt(e)):i}function pt(t){if(t.components)for(var e,i=t.components=vt(t.components),n=Object.keys(i),r=0,s=n.length;s>r;r++){var o=n[r];jn.test(o)||En.test(o)||(e=i[o],g(e)&&(i[o]=wi.extend(e)))}}function dt(t){var e,i,n=t.props;if(Di(n))for(t.props={},e=n.length;e--;)i=n[e],"string"==typeof i?t.props[i]=null:i.name&&(t.props[i.name]=i);else if(g(n)){var r=Object.keys(n);for(e=r.length;e--;)i=n[r[e]],"function"==typeof i&&(n[r[e]]={type:i})}}function vt(t){if(Di(t)){for(var e,i={},n=t.length;n--;){e=t[n];var r="function"==typeof e?e.options&&e.options.name||e.id:e.name||e.id;r&&(i[r]=e)}return i}return t}function mt(t,e,n){function r(i){var r=Sn[i]||Fn;o[i]=r(t[i],e[i],n,i)}pt(e),dt(e);var s,o={};if(e["extends"]&&(t="function"==typeof e["extends"]?mt(t,e["extends"].options,n):mt(t,e["extends"],n)),e.mixins)for(var a=0,h=e.mixins.length;h>a;a++){var l=e.mixins[a],c=l.prototype instanceof wi?l.options:l;t=mt(t,c,n)}for(s in t)r(s);for(s in e)i(t,s)||r(s);return o}function gt(t,e,i,n){if("string"==typeof i){var r,s=t[e],o=s[i]||s[r=l(i)]||s[r.charAt(0).toUpperCase()+r.slice(1)];return o}}function _t(){this.id=Dn++,this.subs=[]}function yt(t){Hn=!1,t(),Hn=!0}function bt(t){if(this.value=t,this.dep=new _t,_(t,"__ob__",this),Di(t)){var e=Pi?wt:Ct;e(t,Rn,Ln),this.observeArray(t)}else this.walk(t)}function wt(t,e){t.__proto__=e}function Ct(t,e,i){for(var n=0,r=i.length;r>n;n++){var s=i[n];_(t,s,e[s])}}function $t(t,e){if(t&&"object"==typeof t){var n;return i(t,"__ob__")&&t.__ob__ instanceof bt?n=t.__ob__:Hn&&(Di(t)||g(t))&&Object.isExtensible(t)&&!t._isVue&&(n=new bt(t)),n&&e&&n.addVm(e),n}}function kt(t,e,i){var n=new _t,r=Object.getOwnPropertyDescriptor(t,e);if(!r||r.configurable!==!1){var s=r&&r.get,o=r&&r.set,a=$t(i);Object.defineProperty(t,e,{enumerable:!0,configurable:!0,get:function(){var e=s?s.call(t):i;if(_t.target&&(n.depend(),a&&a.dep.depend(),Di(e)))for(var r,o=0,h=e.length;h>o;o++)r=e[o],r&&r.__ob__&&r.__ob__.dep.depend();return e},set:function(e){var r=s?s.call(t):i;e!==r&&(o?o.call(t,e):i=e,a=$t(e),n.notify())}})}}function xt(t){t.prototype._init=function(t){t=t||{},this.$el=null,this.$parent=t.parent,this.$root=this.$parent?this.$parent.$root:this,this.$children=[],this.$refs={},this.$els={},this._watchers=[],this._directives=[],this._uid=Mn++,this._isVue=!0,this._events={},this._eventsCount={},this._isFragment=!1,this._fragment=this._fragmentStart=this._fragmentEnd=null,this._isCompiled=this._isDestroyed=this._isReady=this._isAttached=this._isBeingDestroyed=this._vForRemoving=!1,this._unlinkFn=null,this._context=t._context||this.$parent,this._scope=t._scope,this._frag=t._frag,this._frag&&this._frag.children.push(this),this.$parent&&this.$parent.$children.push(this),t=this.$options=mt(this.constructor.options,t,this),this._updateRef(),this._data={},this._callHook("init"),this._initState(),this._initEvents(),this._callHook("created"),t.el&&this.$mount(t.el)}}function At(t){if(void 0===t)return"eof";var e=t.charCodeAt(0);switch(e){case 91:case 93:case 46:case 34:case 39:case 48:return t;case 95:case 36:return"ident";case 32:case 9:case 10:case 13:case 160:case 65279:case 8232:case 8233:return"ws"}return e>=97&&122>=e||e>=65&&90>=e?"ident":e>=49&&57>=e?"number":"else"}function Ot(t){var e=t.trim();return"0"===t.charAt(0)&&isNaN(t)?!1:n(e)?h(e):"*"+e}function Tt(t){function e(){var e=t[c+1];return u===Xn&&"'"===e||u===Yn&&'"'===e?(c++,n="\\"+e,p[Bn](),!0):void 0}var i,n,r,s,o,a,h,l=[],c=-1,u=Jn,f=0,p=[];for(p[Wn]=function(){void 0!==r&&(l.push(r),r=void 0)},p[Bn]=function(){void 0===r?r=n:r+=n},p[zn]=function(){p[Bn](),f++},p[Un]=function(){if(f>0)f--,u=Zn,p[Bn]();else{if(f=0,r=Ot(r),r===!1)return!1;p[Wn]()}};null!=u;)if(c++,i=t[c],"\\"!==i||!e()){if(s=At(i),h=er[u],o=h[s]||h["else"]||tr,o===tr)return;if(u=o[0],a=p[o[1]],a&&(n=o[2],n=void 0===n?i:n,a()===!1))return;if(u===Kn)return l.raw=t,l}}function Nt(t){var e=Vn.get(t);return e||(e=Tt(t),e&&Vn.put(t,e)),e}function jt(t,e){return It(e).get(t)}function Et(e,i,n){var r=e;if("string"==typeof i&&(i=Tt(i)),!i||!m(e))return!1;for(var s,o,a=0,h=i.length;h>a;a++)s=e,o=i[a],"*"===o.charAt(0)&&(o=It(o.slice(1)).get.call(r,r)),h-1>a?(e=e[o],m(e)||(e={},t(s,o,e))):Di(e)?e.$set(o,n):o in e?e[o]=n:t(e,o,n);return!0}function St(){}function Ft(t,e){var i=vr.length;return vr[i]=e?t.replace(lr,"\\n"):t,'"'+i+'"'}function Dt(t){var e=t.charAt(0),i=t.slice(1);return sr.test(i)?t:(i=i.indexOf('"')>-1?i.replace(ur,Pt):i,e+"scope."+i)}function Pt(t,e){return vr[e]}function Rt(t){ar.test(t),vr.length=0;var e=t.replace(cr,Ft).replace(hr,"");return e=(" "+e).replace(pr,Dt).replace(ur,Pt),Lt(e)}function Lt(t){try{return new Function("scope","return "+t+";")}catch(e){return St}}function Ht(t){var e=Nt(t);return e?function(t,i){Et(t,e,i)}:void 0}function It(t,e){t=t.trim();var i=nr.get(t);if(i)return e&&!i.set&&(i.set=Ht(i.exp)),i;var n={exp:t};return n.get=Mt(t)&&t.indexOf("[")<0?Lt("scope."+t):Rt(t),e&&(n.set=Ht(t)),nr.put(t,n),n}function Mt(t){return fr.test(t)&&!dr.test(t)&&"Math."!==t.slice(0,5)}function Vt(){gr.length=0,_r.length=0,yr={},br={},wr=!1}function Bt(){for(var t=!0;t;)t=!1,Wt(gr),Wt(_r),gr.length?t=!0:(Li&&An.devtools&&Li.emit("flush"),Vt())}function Wt(t){for(var e=0;e<t.length;e++){var i=t[e],n=i.id;yr[n]=null,i.run()}t.length=0}function zt(t){var e=t.id;if(null==yr[e]){var i=t.user?_r:gr;yr[e]=i.length,i.push(t),wr||(wr=!0,Yi(Bt))}}function Ut(t,e,i,n){n&&v(this,n);var r="function"==typeof e;if(this.vm=t,t._watchers.push(this),this.expression=e,this.cb=i,this.id=++Cr,this.active=!0,this.dirty=this.lazy,this.deps=[],this.newDeps=[],this.depIds=new Ki,this.newDepIds=new Ki,this.prevError=null,r)this.getter=e,this.setter=void 0;else{var s=It(e,this.twoWay);this.getter=s.get,this.setter=s.set}this.value=this.lazy?void 0:this.get(),this.queued=this.shallow=!1}function Jt(t,e){var i=void 0,n=void 0;e||(e=$r,e.clear());var r=Di(t),s=m(t);if((r||s)&&Object.isExtensible(t)){if(t.__ob__){var o=t.__ob__.dep.id;if(e.has(o))return;e.add(o)}if(r)for(i=t.length;i--;)Jt(t[i],e);else if(s)for(n=Object.keys(t),i=n.length;i--;)Jt(t[n[i]],e)}}function qt(t){return it(t)&&at(t.content)}function Qt(t,e){var i=e?t:t.trim(),n=xr.get(i);if(n)return n;var r=document.createDocumentFragment(),s=t.match(Tr),o=Nr.test(t),a=jr.test(t);if(s||o||a){var h=s&&s[1],l=Or[h]||Or.efault,c=l[0],u=l[1],f=l[2],p=document.createElement("div");for(p.innerHTML=u+t+f;c--;)p=p.lastChild;for(var d;d=p.firstChild;)r.appendChild(d)}else r.appendChild(document.createTextNode(t));return e||tt(r),xr.put(i,r),r}function Gt(t){if(qt(t))return Qt(t.innerHTML);if("SCRIPT"===t.tagName)return Qt(t.textContent);for(var e,i=Zt(t),n=document.createDocumentFragment();e=i.firstChild;)n.appendChild(e);return tt(n),n}function Zt(t){if(!t.querySelectorAll)return t.cloneNode();var e,i,n,r=t.cloneNode(!0);if(Er){var s=r;if(qt(t)&&(t=t.content,s=r.content),i=t.querySelectorAll("template"),i.length)for(n=s.querySelectorAll("template"),e=n.length;e--;)n[e].parentNode.replaceChild(Zt(i[e]),n[e])}if(Sr)if("TEXTAREA"===t.tagName)r.value=t.value;else if(i=t.querySelectorAll("textarea"),i.length)for(n=r.querySelectorAll("textarea"),e=n.length;e--;)n[e].value=i[e].value;return r}function Xt(t,e,i){var n,r;return at(t)?(tt(t),e?Zt(t):t):("string"==typeof t?i||"#"!==t.charAt(0)?r=Qt(t,i):(r=Ar.get(t),r||(n=document.getElementById(t.slice(1)),n&&(r=Gt(n),Ar.put(t,r)))):t.nodeType&&(r=Gt(t)),r&&e?Zt(r):r)}function Yt(t,e,i,n,r,s){this.children=[],this.childFrags=[],this.vm=e,this.scope=r,this.inserted=!1,this.parentFrag=s,s&&s.childFrags.push(this),this.unlink=t(e,i,n,r,this);var o=this.single=1===i.childNodes.length&&!i.childNodes[0].__v_anchor;o?(this.node=i.childNodes[0],this.before=Kt,this.remove=te):(this.node=nt("fragment-start"),this.end=nt("fragment-end"),this.frag=i,U(this.node,i),i.appendChild(this.end),this.before=ee,this.remove=ie),this.node.__v_frag=this}function Kt(t,e){this.inserted=!0;var i=e!==!1?D:B;i(this.node,t,this.vm),H(this.node)&&this.callHook(ne)}function te(){this.inserted=!1;var t=H(this.node),e=this;this.beforeRemove(),P(this.node,this.vm,function(){t&&e.callHook(re),e.destroy()})}function ee(t,e){this.inserted=!0;var i=this.vm,n=e!==!1?D:B;st(this.node,this.end,function(e){n(e,t,i)}),H(this.node)&&this.callHook(ne)}function ie(){this.inserted=!1;var t=this,e=H(this.node);this.beforeRemove(),ot(this.node,this.end,this.vm,this.frag,function(){e&&t.callHook(re),t.destroy()})}function ne(t){!t._isAttached&&H(t.$el)&&t._callHook("attached")}function re(t){t._isAttached&&!H(t.$el)&&t._callHook("detached")}function se(t,e){this.vm=t;var i,n="string"==typeof e;n||it(e)&&!e.hasAttribute("v-if")?i=Xt(e,!0):(i=document.createDocumentFragment(),i.appendChild(e)),this.template=i;var r,s=t.constructor.cid;if(s>0){var o=s+(n?e:ht(e));r=Pr.get(o),r||(r=De(i,t.$options,!0),Pr.put(o,r))}else r=De(i,t.$options,!0);this.linker=r}function oe(t,e,i){var n=t.node.previousSibling;if(n){for(t=n.__v_frag;!(t&&t.forId===i&&t.inserted||n===e);){if(n=n.previousSibling,!n)return;t=n.__v_frag}return t}}function ae(t){var e=t.node;if(t.end)for(;!e.__vue__&&e!==t.end&&e.nextSibling;)e=e.nextSibling;return e.__vue__}function he(t){for(var e=-1,i=new Array(Math.floor(t));++e<t;)i[e]=e;return i}function le(t,e,i,n){return n?"$index"===n?t:n.charAt(0).match(/\w/)?jt(i,n):i[n]:e||i}function ce(t,e,i){for(var n,r,s,o=e?[]:null,a=0,h=t.options.length;h>a;a++)if(n=t.options[a],s=i?n.hasAttribute("selected"):n.selected){if(r=n.hasOwnProperty("_value")?n._value:n.value,!e)return r;o.push(r)}return o}function ue(t,e){for(var i=t.length;i--;)if(C(t[i],e))return i;return-1}function fe(t,e){var i=e.map(function(t){var e=t.charCodeAt(0);return e>47&&58>e?parseInt(t,10):1===t.length&&(e=t.toUpperCase().charCodeAt(0),e>64&&91>e)?e:is[t]});return i=[].concat.apply([],i),function(e){return i.indexOf(e.keyCode)>-1?t.call(this,e):void 0}}function pe(t){return function(e){return e.stopPropagation(),t.call(this,e)}}function de(t){return function(e){return e.preventDefault(),t.call(this,e)}}function ve(t){return function(e){return e.target===e.currentTarget?t.call(this,e):void 0}}function me(t){if(as[t])return as[t];var e=ge(t);return as[t]=as[e]=e,e}function ge(t){t=u(t);var e=l(t),i=e.charAt(0).toUpperCase()+e.slice(1);hs||(hs=document.createElement("div"));var n,r=rs.length;if("filter"!==e&&e in hs.style)return{kebab:t,camel:e};for(;r--;)if(n=ss[r]+i,n in hs.style)return{kebab:rs[r]+t,camel:n}}function _e(t){var e=[];if(Di(t))for(var i=0,n=t.length;n>i;i++){var r=t[i];if(r)if("string"==typeof r)e.push(r);else for(var s in r)r[s]&&e.push(s)}else if(m(t))for(var o in t)t[o]&&e.push(o);return e}function ye(t,e,i){if(e=e.trim(),-1===e.indexOf(" "))return void i(t,e);for(var n=e.split(/\s+/),r=0,s=n.length;s>r;r++)i(t,n[r])}function be(t,e,i){function n(){++s>=r?i():t[s].call(e,n)}var r=t.length,s=0;t[0].call(e,n)}function we(t,e,i){for(var r,s,o,a,h,c,f,p=[],d=Object.keys(e),v=d.length;v--;)s=d[v],r=e[s]||ks,h=l(s),xs.test(h)&&(f={name:s,path:h,options:r,mode:$s.ONE_WAY,raw:null},o=u(s),null===(a=M(t,o))&&(null!==(a=M(t,o+".sync"))?f.mode=$s.TWO_WAY:null!==(a=M(t,o+".once"))&&(f.mode=$s.ONE_TIME)),null!==a?(f.raw=a,c=A(a),a=c.expression,f.filters=c.filters,n(a)&&!c.filters?f.optimizedLiteral=!0:f.dynamic=!0,f.parentPath=a):null!==(a=I(t,o))&&(f.raw=a),p.push(f));return Ce(p)}function Ce(t){return function(e,n){e._props={};for(var r,s,l,c,f,p=e.$options.propsData,d=t.length;d--;)if(r=t[d],f=r.raw,s=r.path,l=r.options,e._props[s]=r,p&&i(p,s)&&ke(e,r,p[s]),null===f)ke(e,r,void 0);else if(r.dynamic)r.mode===$s.ONE_TIME?(c=(n||e._context||e).$get(r.parentPath),ke(e,r,c)):e._context?e._bindDir({name:"prop",def:Os,prop:r},null,null,n):ke(e,r,e.$get(r.parentPath));else if(r.optimizedLiteral){var v=h(f);c=v===f?a(o(f)):v,ke(e,r,c)}else c=l.type!==Boolean||""!==f&&f!==u(r.name)?f:!0,ke(e,r,c)}}function $e(t,e,i,n){var r=e.dynamic&&Mt(e.parentPath),s=i;void 0===s&&(s=Ae(t,e)),s=Te(e,s,t);var o=s!==i;Oe(e,s,t)||(s=void 0),r&&!o?yt(function(){n(s)}):n(s)}function ke(t,e,i){$e(t,e,i,function(i){kt(t,e.path,i)})}function xe(t,e,i){$e(t,e,i,function(i){t[e.path]=i})}function Ae(t,e){var n=e.options;if(!i(n,"default"))return n.type===Boolean?!1:void 0;var r=n["default"];return m(r),"function"==typeof r&&n.type!==Function?r.call(t):r}function Oe(t,e,i){if(!t.options.required&&(null===t.raw||null==e))return!0;var n=t.options,r=n.type,s=!r,o=[];if(r){Di(r)||(r=[r]);for(var a=0;a<r.length&&!s;a++){var h=Ne(e,r[a]);o.push(h.expectedType),s=h.valid}}if(!s)return!1;var l=n.validator;return!l||l(e)}function Te(t,e,i){var n=t.options.coerce;return n&&"function"==typeof n?n(e):e}function Ne(t,e){var i,n;return e===String?(n="string",i=typeof t===n):e===Number?(n="number",i=typeof t===n):e===Boolean?(n="boolean",i=typeof t===n):e===Function?(n="function",i=typeof t===n):e===Object?(n="object",i=g(t)):e===Array?(n="array",i=Di(t)):i=t instanceof e,{valid:i,expectedType:n}}function je(t){Ts.push(t),Ns||(Ns=!0,Yi(Ee))}function Ee(){for(var t=document.documentElement.offsetHeight,e=0;e<Ts.length;e++)Ts[e]();return Ts=[],Ns=!1,t}function Se(t,e,i,n){this.id=e,this.el=t,this.enterClass=i&&i.enterClass||e+"-enter",this.leaveClass=i&&i.leaveClass||e+"-leave",this.hooks=i,this.vm=n,this.pendingCssEvent=this.pendingCssCb=this.cancel=this.pendingJsCb=this.op=this.cb=null,this.justEntered=!1,this.entered=this.left=!1,this.typeCache={},this.type=i&&i.type;var r=this;["enterNextTick","enterDone","leaveNextTick","leaveDone"].forEach(function(t){r[t]=p(r[t],r)})}function Fe(t){if(/svg$/.test(t.namespaceURI)){var e=t.getBoundingClientRect();return!(e.width||e.height)}return!(t.offsetWidth||t.offsetHeight||t.getClientRects().length)}function De(t,e,i){var n=i||!e._asComponent?Ve(t,e):null,r=n&&n.terminal||ri(t)||!t.hasChildNodes()?null:qe(t.childNodes,e);return function(t,e,i,s,o){var a=d(e.childNodes),h=Pe(function(){n&&n(t,e,i,s,o),r&&r(t,a,i,s,o)},t);return Le(t,h)}}function Pe(t,e){e._directives=[];var i=e._directives.length;t();var n=e._directives.slice(i);n.sort(Re);for(var r=0,s=n.length;s>r;r++)n[r]._bind();return n}function Re(t,e){return t=t.descriptor.def.priority||zs,e=e.descriptor.def.priority||zs,t>e?-1:t===e?0:1}function Le(t,e,i,n){function r(r){He(t,e,r),i&&n&&He(i,n)}return r.dirs=e,r}function He(t,e,i){for(var n=e.length;n--;)e[n]._teardown()}function Ie(t,e,i,n){var r=we(e,i,t),s=Pe(function(){r(t,n)},t);return Le(t,s)}function Me(t,e,i){var n,r,s=e._containerAttrs,o=e._replacerAttrs;return 11!==t.nodeType&&(e._asComponent?(s&&i&&(n=ti(s,i)),o&&(r=ti(o,e))):r=ti(t.attributes,e)),e._containerAttrs=e._replacerAttrs=null,function(t,e,i){var s,o=t._context;o&&n&&(s=Pe(function(){n(o,e,null,i)},o));var a=Pe(function(){r&&r(t,e)},t);return Le(t,a,o,s)}}function Ve(t,e){var i=t.nodeType;return 1!==i||ri(t)?3===i&&t.data.trim()?We(t,e):null:Be(t,e)}function Be(t,e){if("TEXTAREA"===t.tagName){var i=N(t.value);i&&(t.setAttribute(":value",j(i)),t.value="")}var n,r=t.hasAttributes(),s=r&&d(t.attributes);return r&&(n=Xe(t,s,e)),n||(n=Ge(t,e)),n||(n=Ze(t,e)),!n&&r&&(n=ti(s,e)),n}function We(t,e){if(t._skip)return ze;var i=N(t.wholeText);if(!i)return null;for(var n=t.nextSibling;n&&3===n.nodeType;)n._skip=!0,n=n.nextSibling;for(var r,s,o=document.createDocumentFragment(),a=0,h=i.length;h>a;a++)s=i[a],r=s.tag?Ue(s,e):document.createTextNode(s.value),o.appendChild(r);return Je(i,o,e)}function ze(t,e){z(e)}function Ue(t,e){function i(e){if(!t.descriptor){var i=A(t.value);t.descriptor={name:e,def:bs[e],expression:i.expression,filters:i.filters}}}var n;return t.oneTime?n=document.createTextNode(t.value):t.html?(n=document.createComment("v-html"),i("html")):(n=document.createTextNode(" "),i("text")),n}function Je(t,e){return function(i,n,r,o){for(var a,h,l,c=e.cloneNode(!0),u=d(c.childNodes),f=0,p=t.length;p>f;f++)a=t[f],h=a.value,a.tag&&(l=u[f],a.oneTime?(h=(o||i).$eval(h),a.html?J(l,Xt(h,!0)):l.data=s(h)):i._bindDir(a.descriptor,l,r,o));J(n,c)}}function qe(t,e){for(var i,n,r,s=[],o=0,a=t.length;a>o;o++)r=t[o],i=Ve(r,e),n=i&&i.terminal||"SCRIPT"===r.tagName||!r.hasChildNodes()?null:qe(r.childNodes,e),s.push(i,n);return s.length?Qe(s):null}function Qe(t){return function(e,i,n,r,s){for(var o,a,h,l=0,c=0,u=t.length;u>l;c++){o=i[c],a=t[l++],h=t[l++];var f=d(o.childNodes);a&&a(e,o,n,r,s),h&&h(e,f,n,r,s)}}}function Ge(t,e){var i=t.tagName.toLowerCase();if(!jn.test(i)){var n=gt(e,"elementDirectives",i);return n?Ke(t,i,"",e,n):void 0}}function Ze(t,e){var i=lt(t,e);if(i){var n=rt(t),r={name:"component",ref:n,expression:i.id,def:Hs.component,modifiers:{literal:!i.dynamic}},s=function(t,e,i,s,o){n&&kt((s||t).$refs,n,null),t._bindDir(r,e,i,s,o)};return s.terminal=!0,s}}function Xe(t,e,i){if(null!==I(t,"v-pre"))return Ye;if(t.hasAttribute("v-else")){var n=t.previousElementSibling;if(n&&n.hasAttribute("v-if"))return Ye}for(var r,s,o,a,h,l,c,u,f,p,d=0,v=e.length;v>d;d++)r=e[d],s=r.name.replace(Bs,""),(h=s.match(Vs))&&(f=gt(i,"directives",h[1]),f&&f.terminal&&(!p||(f.priority||Us)>p.priority)&&(p=f,c=r.name,a=ei(r.name),o=r.value,l=h[1],u=h[2]));return p?Ke(t,l,o,i,p,c,u,a):void 0}function Ye(){}function Ke(t,e,i,n,r,s,o,a){var h=A(i),l={name:e,arg:o,expression:h.expression,filters:h.filters,raw:i,attr:s,modifiers:a,def:r};"for"!==e&&"router-view"!==e||(l.ref=rt(t));var c=function(t,e,i,n,r){l.ref&&kt((n||t).$refs,l.ref,null),t._bindDir(l,e,i,n,r)};return c.terminal=!0,c}function ti(t,e){function i(t,e,i){var n=i&&ni(i),r=!n&&A(s);v.push({name:t,attr:o,raw:a,def:e,arg:l,modifiers:c,expression:r&&r.expression,filters:r&&r.filters,interp:i,hasOneTime:n})}for(var n,r,s,o,a,h,l,c,u,f,p,d=t.length,v=[];d--;)if(n=t[d],r=o=n.name,s=a=n.value,f=N(s),l=null,c=ei(r),r=r.replace(Bs,""),f)s=j(f),l=r,i("bind",bs.bind,f);else if(Ws.test(r))c.literal=!Is.test(r),i("transition",Hs.transition);else if(Ms.test(r))l=r.replace(Ms,""),i("on",bs.on);else if(Is.test(r))h=r.replace(Is,""),"style"===h||"class"===h?i(h,Hs[h]):(l=h,i("bind",bs.bind));else if(p=r.match(Vs)){if(h=p[1],l=p[2],"else"===h)continue;u=gt(e,"directives",h,!0),u&&i(h,u)}return v.length?ii(v):void 0}function ei(t){var e=Object.create(null),i=t.match(Bs);if(i)for(var n=i.length;n--;)e[i[n].slice(1)]=!0;return e}function ii(t){return function(e,i,n,r,s){for(var o=t.length;o--;)e._bindDir(t[o],i,n,r,s)}}function ni(t){for(var e=t.length;e--;)if(t[e].oneTime)return!0}function ri(t){return"SCRIPT"===t.tagName&&(!t.hasAttribute("type")||"text/javascript"===t.getAttribute("type"))}function si(t,e){return e&&(e._containerAttrs=ai(t)),it(t)&&(t=Xt(t)),e&&(e._asComponent&&!e.template&&(e.template="<slot></slot>"),e.template&&(e._content=K(t),t=oi(t,e))),at(t)&&(U(nt("v-start",!0),t),t.appendChild(nt("v-end",!0))),t}function oi(t,e){var i=e.template,n=Xt(i,!0);if(n){var r=n.firstChild,s=r.tagName&&r.tagName.toLowerCase();return e.replace?(t===document.body,n.childNodes.length>1||1!==r.nodeType||"component"===s||gt(e,"components",s)||V(r,"is")||gt(e,"elementDirectives",s)||r.hasAttribute("v-for")||r.hasAttribute("v-if")?n:(e._replacerAttrs=ai(r),hi(t,r),r)):(t.appendChild(n),t)}}function ai(t){return 1===t.nodeType&&t.hasAttributes()?d(t.attributes):void 0}function hi(t,e){for(var i,n,r=t.attributes,s=r.length;s--;)i=r[s].name,n=r[s].value,e.hasAttribute(i)||Js.test(i)?"class"===i&&!N(n)&&(n=n.trim())&&n.split(/\s+/).forEach(function(t){X(e,t)}):e.setAttribute(i,n)}function li(t,e){if(e){for(var i,n,r=t._slotContents=Object.create(null),s=0,o=e.children.length;o>s;s++)i=e.children[s],(n=i.getAttribute("slot"))&&(r[n]||(r[n]=[])).push(i);for(n in r)r[n]=ci(r[n],e);if(e.hasChildNodes()){var a=e.childNodes;if(1===a.length&&3===a[0].nodeType&&!a[0].data.trim())return;r["default"]=ci(e.childNodes,e)}}}function ci(t,e){var i=document.createDocumentFragment();t=d(t);for(var n=0,r=t.length;r>n;n++){var s=t[n];!it(s)||s.hasAttribute("v-if")||s.hasAttribute("v-for")||(e.removeChild(s),s=Xt(s,!0)),i.appendChild(s)}return i}function ui(t){function e(){}function n(t,e){var i=new Ut(e,t,null,{lazy:!0});return function(){return i.dirty&&i.evaluate(),_t.target&&i.depend(),i.value}}Object.defineProperty(t.prototype,"$data",{get:function(){return this._data},set:function(t){t!==this._data&&this._setData(t)}}),t.prototype._initState=function(){this._initProps(),this._initMeta(),this._initMethods(),this._initData(),this._initComputed()},t.prototype._initProps=function(){var t=this.$options,e=t.el,i=t.props;e=t.el=L(e),this._propsUnlinkFn=e&&1===e.nodeType&&i?Ie(this,e,i,this._scope):null},t.prototype._initData=function(){var t=this.$options.data,e=this._data=t?t():{};g(e)||(e={});var n,r,s=this._props,o=Object.keys(e);for(n=o.length;n--;)r=o[n],s&&i(s,r)||this._proxy(r);$t(e,this)},t.prototype._setData=function(t){t=t||{};var e=this._data;this._data=t;var n,r,s;for(n=Object.keys(e),s=n.length;s--;)r=n[s],r in t||this._unproxy(r);for(n=Object.keys(t),s=n.length;s--;)r=n[s],i(this,r)||this._proxy(r);e.__ob__.removeVm(this),$t(t,this),this._digest()},t.prototype._proxy=function(t){if(!r(t)){var e=this;Object.defineProperty(e,t,{configurable:!0,enumerable:!0,get:function(){return e._data[t]},set:function(i){e._data[t]=i}})}},t.prototype._unproxy=function(t){r(t)||delete this[t]},t.prototype._digest=function(){for(var t=0,e=this._watchers.length;e>t;t++)this._watchers[t].update(!0)},t.prototype._initComputed=function(){var t=this.$options.computed;if(t)for(var i in t){var r=t[i],s={enumerable:!0,configurable:!0};"function"==typeof r?(s.get=n(r,this),s.set=e):(s.get=r.get?r.cache!==!1?n(r.get,this):p(r.get,this):e,s.set=r.set?p(r.set,this):e),Object.defineProperty(this,i,s)}},t.prototype._initMethods=function(){var t=this.$options.methods;if(t)for(var e in t)this[e]=p(t[e],this)},t.prototype._initMeta=function(){var t=this.$options._meta;if(t)for(var e in t)kt(this,e,t[e])}}function fi(t){function e(t,e){for(var i,n,r,s=e.attributes,o=0,a=s.length;a>o;o++)i=s[o].name,Qs.test(i)&&(i=i.replace(Qs,""),n=s[o].value,Mt(n)&&(n+=".apply(this, $arguments)"),r=(t._scope||t._context).$eval(n,!0),r._fromParent=!0,t.$on(i.replace(Qs),r))}function i(t,e,i){if(i){var r,s,o,a;for(s in i)if(r=i[s],Di(r))for(o=0,a=r.length;a>o;o++)n(t,e,s,r[o]);else n(t,e,s,r)}}function n(t,e,i,r,s){var o=typeof r;if("function"===o)t[e](i,r,s);else if("string"===o){var a=t.$options.methods,h=a&&a[r];h&&t[e](i,h,s)}else r&&"object"===o&&n(t,e,i,r.handler,r)}function r(){this._isAttached||(this._isAttached=!0,this.$children.forEach(s))}function s(t){!t._isAttached&&H(t.$el)&&t._callHook("attached")}function o(){this._isAttached&&(this._isAttached=!1,this.$children.forEach(a))}function a(t){t._isAttached&&!H(t.$el)&&t._callHook("detached")}t.prototype._initEvents=function(){var t=this.$options;t._asComponent&&e(this,t.el),i(this,"$on",t.events),i(this,"$watch",t.watch)},t.prototype._initDOMHooks=function(){this.$on("hook:attached",r),this.$on("hook:detached",o)},t.prototype._callHook=function(t){this.$emit("pre-hook:"+t);var e=this.$options[t];if(e)for(var i=0,n=e.length;n>i;i++)e[i].call(this);this.$emit("hook:"+t)}}function pi(){}function di(t,e,i,n,r,s){this.vm=e,this.el=i,this.descriptor=t,this.name=t.name,this.expression=t.expression,this.arg=t.arg,this.modifiers=t.modifiers,this.filters=t.filters,this.literal=this.modifiers&&this.modifiers.literal,this._locked=!1,this._bound=!1,this._listeners=null,this._host=n,this._scope=r,this._frag=s}function vi(t){t.prototype._updateRef=function(t){var e=this.$options._ref;if(e){var i=(this._scope||this._context).$refs;t?i[e]===this&&(i[e]=null):i[e]=this}},t.prototype._compile=function(t){var e=this.$options,i=t;if(t=si(t,e),this._initElement(t),1!==t.nodeType||null===I(t,"v-pre")){var n=this._context&&this._context.$options,r=Me(t,e,n);li(this,e._content);var s,o=this.constructor;e._linkerCachable&&(s=o.linker,s||(s=o.linker=De(t,e)));var a=r(this,t,this._scope),h=s?s(this,t):De(t,e)(this,t);this._unlinkFn=function(){a(),h(!0)},e.replace&&J(i,t),this._isCompiled=!0,this._callHook("compiled")}},t.prototype._initElement=function(t){at(t)?(this._isFragment=!0,this.$el=this._fragmentStart=t.firstChild,this._fragmentEnd=t.lastChild,3===this._fragmentStart.nodeType&&(this._fragmentStart.data=this._fragmentEnd.data=""),this._fragment=t):this.$el=t,this.$el.__vue__=this,this._callHook("beforeCompile")},t.prototype._bindDir=function(t,e,i,n,r){this._directives.push(new di(t,this,e,i,n,r))},t.prototype._destroy=function(t,e){if(this._isBeingDestroyed)return void(e||this._cleanup());var i,n,r=this,s=function(){!i||n||e||r._cleanup()};t&&this.$el&&(n=!0,this.$remove(function(){ +n=!1,s()})),this._callHook("beforeDestroy"),this._isBeingDestroyed=!0;var o,a=this.$parent;for(a&&!a._isBeingDestroyed&&(a.$children.$remove(this),this._updateRef(!0)),o=this.$children.length;o--;)this.$children[o].$destroy();for(this._propsUnlinkFn&&this._propsUnlinkFn(),this._unlinkFn&&this._unlinkFn(),o=this._watchers.length;o--;)this._watchers[o].teardown();this.$el&&(this.$el.__vue__=null),i=!0,s()},t.prototype._cleanup=function(){this._isDestroyed||(this._frag&&this._frag.children.$remove(this),this._data&&this._data.__ob__&&this._data.__ob__.removeVm(this),this.$el=this.$parent=this.$root=this.$children=this._watchers=this._context=this._scope=this._directives=null,this._isDestroyed=!0,this._callHook("destroyed"),this.$off())}}function mi(t){t.prototype._applyFilters=function(t,e,i,n){var r,s,o,a,h,l,c,u,f;for(l=0,c=i.length;c>l;l++)if(r=i[n?c-l-1:l],s=gt(this.$options,"filters",r.name,!0),s&&(s=n?s.write:s.read||s,"function"==typeof s)){if(o=n?[t,e]:[t],h=n?2:1,r.args)for(u=0,f=r.args.length;f>u;u++)a=r.args[u],o[u+h]=a.dynamic?this.$get(a.value):a.value;t=s.apply(this,o)}return t},t.prototype._resolveComponent=function(e,i){var n;if(n="function"==typeof e?e:gt(this.$options,"components",e,!0))if(n.options)i(n);else if(n.resolved)i(n.resolved);else if(n.requested)n.pendingCallbacks.push(i);else{n.requested=!0;var r=n.pendingCallbacks=[i];n.call(this,function(e){g(e)&&(e=t.extend(e)),n.resolved=e;for(var i=0,s=r.length;s>i;i++)r[i](e)},function(t){})}}}function gi(t){function i(t){return JSON.parse(JSON.stringify(t))}t.prototype.$get=function(t,e){var i=It(t);if(i){if(e){var n=this;return function(){n.$arguments=d(arguments);var t=i.get.call(n,n);return n.$arguments=null,t}}try{return i.get.call(this,this)}catch(r){}}},t.prototype.$set=function(t,e){var i=It(t,!0);i&&i.set&&i.set.call(this,this,e)},t.prototype.$delete=function(t){e(this._data,t)},t.prototype.$watch=function(t,e,i){var n,r=this;"string"==typeof t&&(n=A(t),t=n.expression);var s=new Ut(r,t,e,{deep:i&&i.deep,sync:i&&i.sync,filters:n&&n.filters,user:!i||i.user!==!1});return i&&i.immediate&&e.call(r,s.value),function(){s.teardown()}},t.prototype.$eval=function(t,e){if(Gs.test(t)){var i=A(t),n=this.$get(i.expression,e);return i.filters?this._applyFilters(n,null,i.filters):n}return this.$get(t,e)},t.prototype.$interpolate=function(t){var e=N(t),i=this;return e?1===e.length?i.$eval(e[0].value)+"":e.map(function(t){return t.tag?i.$eval(t.value):t.value}).join(""):t},t.prototype.$log=function(t){var e=t?jt(this._data,t):this._data;if(e&&(e=i(e)),!t){var n;for(n in this.$options.computed)e[n]=i(this[n]);if(this._props)for(n in this._props)e[n]=i(this[n])}console.log(e)}}function _i(t){function e(t,e,n,r,s,o){e=i(e);var a=!H(e),h=r===!1||a?s:o,l=!a&&!t._isAttached&&!H(t.$el);return t._isFragment?(st(t._fragmentStart,t._fragmentEnd,function(i){h(i,e,t)}),n&&n()):h(t.$el,e,t,n),l&&t._callHook("attached"),t}function i(t){return"string"==typeof t?document.querySelector(t):t}function n(t,e,i,n){e.appendChild(t),n&&n()}function r(t,e,i,n){B(t,e),n&&n()}function s(t,e,i){z(t),i&&i()}t.prototype.$nextTick=function(t){Yi(t,this)},t.prototype.$appendTo=function(t,i,r){return e(this,t,i,r,n,F)},t.prototype.$prependTo=function(t,e,n){return t=i(t),t.hasChildNodes()?this.$before(t.firstChild,e,n):this.$appendTo(t,e,n),this},t.prototype.$before=function(t,i,n){return e(this,t,i,n,r,D)},t.prototype.$after=function(t,e,n){return t=i(t),t.nextSibling?this.$before(t.nextSibling,e,n):this.$appendTo(t.parentNode,e,n),this},t.prototype.$remove=function(t,e){if(!this.$el.parentNode)return t&&t();var i=this._isAttached&&H(this.$el);i||(e=!1);var n=this,r=function(){i&&n._callHook("detached"),t&&t()};if(this._isFragment)ot(this._fragmentStart,this._fragmentEnd,this,this._fragment,r);else{var o=e===!1?s:P;o(this.$el,this,r)}return this}}function yi(t){function e(t,e,n){var r=t.$parent;if(r&&n&&!i.test(e))for(;r;)r._eventsCount[e]=(r._eventsCount[e]||0)+n,r=r.$parent}t.prototype.$on=function(t,i){return(this._events[t]||(this._events[t]=[])).push(i),e(this,t,1),this},t.prototype.$once=function(t,e){function i(){n.$off(t,i),e.apply(this,arguments)}var n=this;return i.fn=e,this.$on(t,i),this},t.prototype.$off=function(t,i){var n;if(!arguments.length){if(this.$parent)for(t in this._events)n=this._events[t],n&&e(this,t,-n.length);return this._events={},this}if(n=this._events[t],!n)return this;if(1===arguments.length)return e(this,t,-n.length),this._events[t]=null,this;for(var r,s=n.length;s--;)if(r=n[s],r===i||r.fn===i){e(this,t,-1),n.splice(s,1);break}return this},t.prototype.$emit=function(t){var e="string"==typeof t;t=e?t:t.name;var i=this._events[t],n=e||!i;if(i){i=i.length>1?d(i):i;var r=e&&i.some(function(t){return t._fromParent});r&&(n=!1);for(var s=d(arguments,1),o=0,a=i.length;a>o;o++){var h=i[o],l=h.apply(this,s);l!==!0||r&&!h._fromParent||(n=!0)}}return n},t.prototype.$broadcast=function(t){var e="string"==typeof t;if(t=e?t:t.name,this._eventsCount[t]){var i=this.$children,n=d(arguments);e&&(n[0]={name:t,source:this});for(var r=0,s=i.length;s>r;r++){var o=i[r],a=o.$emit.apply(o,n);a&&o.$broadcast.apply(o,n)}return this}},t.prototype.$dispatch=function(t){var e=this.$emit.apply(this,arguments);if(e){var i=this.$parent,n=d(arguments);for(n[0]={name:t,source:this};i;)e=i.$emit.apply(i,n),i=e?i.$parent:null;return this}};var i=/^hook:/}function bi(t){function e(){this._isAttached=!0,this._isReady=!0,this._callHook("ready")}t.prototype.$mount=function(t){return this._isCompiled?void 0:(t=L(t),t||(t=document.createElement("div")),this._compile(t),this._initDOMHooks(),H(this.$el)?(this._callHook("attached"),e.call(this)):this.$once("hook:attached",e),this)},t.prototype.$destroy=function(t,e){this._destroy(t,e)},t.prototype.$compile=function(t,e,i,n){return De(t,this.$options,!0)(this,t,e,i,n)}}function wi(t){this._init(t)}function Ci(t,e,i){return i=i?parseInt(i,10):0,e=o(e),"number"==typeof e?t.slice(i,i+e):t}function $i(t,e,i){if(t=Ks(t),null==e)return t;if("function"==typeof e)return t.filter(e);e=(""+e).toLowerCase();for(var n,r,s,o,a="in"===i?3:2,h=Array.prototype.concat.apply([],d(arguments,a)),l=[],c=0,u=t.length;u>c;c++)if(n=t[c],s=n&&n.$value||n,o=h.length){for(;o--;)if(r=h[o],"$key"===r&&xi(n.$key,e)||xi(jt(s,r),e)){l.push(n);break}}else xi(n,e)&&l.push(n);return l}function ki(t){function e(t,e,i){var r=n[i];return r&&("$key"!==r&&(m(t)&&"$value"in t&&(t=t.$value),m(e)&&"$value"in e&&(e=e.$value)),t=m(t)?jt(t,r):t,e=m(e)?jt(e,r):e),t===e?0:t>e?s:-s}var i=null,n=void 0;t=Ks(t);var r=d(arguments,1),s=r[r.length-1];"number"==typeof s?(s=0>s?-1:1,r=r.length>1?r.slice(0,-1):r):s=1;var o=r[0];return o?("function"==typeof o?i=function(t,e){return o(t,e)*s}:(n=Array.prototype.concat.apply([],r),i=function(t,r,s){return s=s||0,s>=n.length-1?e(t,r,s):e(t,r,s)||i(t,r,s+1)}),t.slice().sort(i)):t}function xi(t,e){var i;if(g(t)){var n=Object.keys(t);for(i=n.length;i--;)if(xi(t[n[i]],e))return!0}else if(Di(t)){for(i=t.length;i--;)if(xi(t[i],e))return!0}else if(null!=t)return t.toString().toLowerCase().indexOf(e)>-1}function Ai(i){function n(t){return new Function("return function "+f(t)+" (options) { this._init(options) }")()}i.options={directives:bs,elementDirectives:Ys,filters:eo,transitions:{},components:{},partials:{},replace:!0},i.util=In,i.config=An,i.set=t,i["delete"]=e,i.nextTick=Yi,i.compiler=qs,i.FragmentFactory=se,i.internalDirectives=Hs,i.parsers={path:ir,text:$n,template:Fr,directive:gn,expression:mr},i.cid=0;var r=1;i.extend=function(t){t=t||{};var e=this,i=0===e.cid;if(i&&t._Ctor)return t._Ctor;var s=t.name||e.options.name,o=n(s||"VueComponent");return o.prototype=Object.create(e.prototype),o.prototype.constructor=o,o.cid=r++,o.options=mt(e.options,t),o["super"]=e,o.extend=e.extend,An._assetTypes.forEach(function(t){o[t]=e[t]}),s&&(o.options.components[s]=o),i&&(t._Ctor=o),o},i.use=function(t){if(!t.installed){var e=d(arguments,1);return e.unshift(this),"function"==typeof t.install?t.install.apply(t,e):t.apply(null,e),t.installed=!0,this}},i.mixin=function(t){i.options=mt(i.options,t)},An._assetTypes.forEach(function(t){i[t]=function(e,n){return n?("component"===t&&g(n)&&(n.name||(n.name=e),n=i.extend(n)),this.options[t+"s"][e]=n,n):this.options[t+"s"][e]}}),v(i.transition,Tn)}var Oi=Object.prototype.hasOwnProperty,Ti=/^\s?(true|false|-?[\d\.]+|'[^']*'|"[^"]*")\s?$/,Ni=/-(\w)/g,ji=/([a-z\d])([A-Z])/g,Ei=/(?:^|[-_\/])(\w)/g,Si=Object.prototype.toString,Fi="[object Object]",Di=Array.isArray,Pi="__proto__"in{},Ri="undefined"!=typeof window&&"[object Object]"!==Object.prototype.toString.call(window),Li=Ri&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__,Hi=Ri&&window.navigator.userAgent.toLowerCase(),Ii=Hi&&Hi.indexOf("trident")>0,Mi=Hi&&Hi.indexOf("msie 9.0")>0,Vi=Hi&&Hi.indexOf("android")>0,Bi=Hi&&/(iphone|ipad|ipod|ios)/i.test(Hi),Wi=Bi&&Hi.match(/os ([\d_]+)/),zi=Wi&&Wi[1].split("_"),Ui=zi&&Number(zi[0])>=9&&Number(zi[1])>=3&&!window.indexedDB,Ji=void 0,qi=void 0,Qi=void 0,Gi=void 0;if(Ri&&!Mi){var Zi=void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend,Xi=void 0===window.onanimationend&&void 0!==window.onwebkitanimationend;Ji=Zi?"WebkitTransition":"transition",qi=Zi?"webkitTransitionEnd":"transitionend",Qi=Xi?"WebkitAnimation":"animation",Gi=Xi?"webkitAnimationEnd":"animationend"}var Yi=function(){function t(){n=!1;var t=i.slice(0);i=[];for(var e=0;e<t.length;e++)t[e]()}var e,i=[],n=!1;if("undefined"==typeof MutationObserver||Ui){var r=Ri?window:"undefined"!=typeof global?global:{};e=r.setImmediate||setTimeout}else{var s=1,o=new MutationObserver(t),a=document.createTextNode(s);o.observe(a,{characterData:!0}),e=function(){s=(s+1)%2,a.data=s}}return function(r,s){var o=s?function(){r.call(s)}:r;i.push(o),n||(n=!0,e(t,0))}}(),Ki=void 0;"undefined"!=typeof Set&&Set.toString().match(/native code/)?Ki=Set:(Ki=function(){this.set=Object.create(null)},Ki.prototype.has=function(t){return void 0!==this.set[t]},Ki.prototype.add=function(t){this.set[t]=1},Ki.prototype.clear=function(){this.set=Object.create(null)});var tn=$.prototype;tn.put=function(t,e){var i,n=this.get(t,!0);return n||(this.size===this.limit&&(i=this.shift()),n={key:t},this._keymap[t]=n,this.tail?(this.tail.newer=n,n.older=this.tail):this.head=n,this.tail=n,this.size++),n.value=e,i},tn.shift=function(){var t=this.head;return t&&(this.head=this.head.newer,this.head.older=void 0,t.newer=t.older=void 0,this._keymap[t.key]=void 0,this.size--),t},tn.get=function(t,e){var i=this._keymap[t];if(void 0!==i)return i===this.tail?e?i:i.value:(i.newer&&(i===this.head&&(this.head=i.newer),i.newer.older=i.older),i.older&&(i.older.newer=i.newer),i.newer=void 0,i.older=this.tail,this.tail&&(this.tail.newer=i),this.tail=i,e?i:i.value)};var en,nn,rn,sn,on,an,hn,ln,cn,un,fn,pn,dn=new $(1e3),vn=/[^\s'"]+|'[^']*'|"[^"]*"/g,mn=/^in$|^-?\d+/,gn=Object.freeze({parseDirective:A}),_n=/[-.*+?^${}()|[\]\/\\]/g,yn=void 0,bn=void 0,wn=void 0,Cn=/[^|]\|[^|]/,$n=Object.freeze({compileRegex:T,parseText:N,tokensToExp:j}),kn=["{{","}}"],xn=["{{{","}}}"],An=Object.defineProperties({debug:!1,silent:!1,async:!0,warnExpressionErrors:!0,devtools:!1,_delimitersChanged:!0,_assetTypes:["component","directive","elementDirective","filter","transition","partial"],_propBindingModes:{ONE_WAY:0,TWO_WAY:1,ONE_TIME:2},_maxUpdateCount:100},{delimiters:{get:function(){return kn},set:function(t){kn=t,T()},configurable:!0,enumerable:!0},unsafeDelimiters:{get:function(){return xn},set:function(t){xn=t,T()},configurable:!0,enumerable:!0}}),On=void 0,Tn=Object.freeze({appendWithTransition:F,beforeWithTransition:D,removeWithTransition:P,applyTransition:R}),Nn=/^v-ref:/,jn=/^(div|p|span|img|a|b|i|br|ul|ol|li|h1|h2|h3|h4|h5|h6|code|pre|table|th|td|tr|form|label|input|select|option|nav|article|section|header|footer)$/i,En=/^(slot|partial|component)$/i,Sn=An.optionMergeStrategies=Object.create(null);Sn.data=function(t,e,i){return i?t||e?function(){var n="function"==typeof e?e.call(i):e,r="function"==typeof t?t.call(i):void 0;return n?ut(n,r):r}:void 0:e?"function"!=typeof e?t:t?function(){return ut(e.call(this),t.call(this))}:e:t},Sn.el=function(t,e,i){if(i||!e||"function"==typeof e){var n=e||t;return i&&"function"==typeof n?n.call(i):n}},Sn.init=Sn.created=Sn.ready=Sn.attached=Sn.detached=Sn.beforeCompile=Sn.compiled=Sn.beforeDestroy=Sn.destroyed=Sn.activate=function(t,e){return e?t?t.concat(e):Di(e)?e:[e]:t},An._assetTypes.forEach(function(t){Sn[t+"s"]=ft}),Sn.watch=Sn.events=function(t,e){if(!e)return t;if(!t)return e;var i={};v(i,t);for(var n in e){var r=i[n],s=e[n];r&&!Di(r)&&(r=[r]),i[n]=r?r.concat(s):[s]}return i},Sn.props=Sn.methods=Sn.computed=function(t,e){if(!e)return t;if(!t)return e;var i=Object.create(null);return v(i,t),v(i,e),i};var Fn=function(t,e){return void 0===e?t:e},Dn=0;_t.target=null,_t.prototype.addSub=function(t){this.subs.push(t)},_t.prototype.removeSub=function(t){this.subs.$remove(t)},_t.prototype.depend=function(){_t.target.addDep(this)},_t.prototype.notify=function(){for(var t=d(this.subs),e=0,i=t.length;i>e;e++)t[e].update()};var Pn=Array.prototype,Rn=Object.create(Pn);["push","pop","shift","unshift","splice","sort","reverse"].forEach(function(t){var e=Pn[t];_(Rn,t,function(){for(var i=arguments.length,n=new Array(i);i--;)n[i]=arguments[i];var r,s=e.apply(this,n),o=this.__ob__;switch(t){case"push":r=n;break;case"unshift":r=n;break;case"splice":r=n.slice(2)}return r&&o.observeArray(r),o.dep.notify(),s})}),_(Pn,"$set",function(t,e){return t>=this.length&&(this.length=Number(t)+1),this.splice(t,1,e)[0]}),_(Pn,"$remove",function(t){if(this.length){var e=b(this,t);return e>-1?this.splice(e,1):void 0}});var Ln=Object.getOwnPropertyNames(Rn),Hn=!0;bt.prototype.walk=function(t){for(var e=Object.keys(t),i=0,n=e.length;n>i;i++)this.convert(e[i],t[e[i]])},bt.prototype.observeArray=function(t){for(var e=0,i=t.length;i>e;e++)$t(t[e])},bt.prototype.convert=function(t,e){kt(this.value,t,e)},bt.prototype.addVm=function(t){(this.vms||(this.vms=[])).push(t)},bt.prototype.removeVm=function(t){this.vms.$remove(t)};var In=Object.freeze({defineReactive:kt,set:t,del:e,hasOwn:i,isLiteral:n,isReserved:r,_toString:s,toNumber:o,toBoolean:a,stripQuotes:h,camelize:l,hyphenate:u,classify:f,bind:p,toArray:d,extend:v,isObject:m,isPlainObject:g,def:_,debounce:y,indexOf:b,cancellable:w,looseEqual:C,isArray:Di,hasProto:Pi,inBrowser:Ri,devtools:Li,isIE:Ii,isIE9:Mi,isAndroid:Vi,isIos:Bi,iosVersionMatch:Wi,iosVersion:zi,hasMutationObserverBug:Ui,get transitionProp(){return Ji},get transitionEndEvent(){return qi},get animationProp(){return Qi},get animationEndEvent(){return Gi},nextTick:Yi,get _Set(){return Ki},query:L,inDoc:H,getAttr:I,getBindAttr:M,hasBindAttr:V,before:B,after:W,remove:z,prepend:U,replace:J,on:q,off:Q,setClass:Z,addClass:X,removeClass:Y,extractContent:K,trimNode:tt,isTemplate:it,createAnchor:nt,findRef:rt,mapNodeRange:st,removeNodeRange:ot,isFragment:at,getOuterHTML:ht,mergeOptions:mt,resolveAsset:gt,checkComponentAttr:lt,commonTagRE:jn,reservedTagRE:En,warn:On}),Mn=0,Vn=new $(1e3),Bn=0,Wn=1,zn=2,Un=3,Jn=0,qn=1,Qn=2,Gn=3,Zn=4,Xn=5,Yn=6,Kn=7,tr=8,er=[];er[Jn]={ws:[Jn],ident:[Gn,Bn],"[":[Zn],eof:[Kn]},er[qn]={ws:[qn],".":[Qn],"[":[Zn],eof:[Kn]},er[Qn]={ws:[Qn],ident:[Gn,Bn]},er[Gn]={ident:[Gn,Bn],0:[Gn,Bn],number:[Gn,Bn],ws:[qn,Wn],".":[Qn,Wn],"[":[Zn,Wn],eof:[Kn,Wn]},er[Zn]={"'":[Xn,Bn],'"':[Yn,Bn],"[":[Zn,zn],"]":[qn,Un],eof:tr,"else":[Zn,Bn]},er[Xn]={"'":[Zn,Bn],eof:tr,"else":[Xn,Bn]},er[Yn]={'"':[Zn,Bn],eof:tr,"else":[Yn,Bn]};var ir=Object.freeze({parsePath:Nt,getPath:jt,setPath:Et}),nr=new $(1e3),rr="Math,Date,this,true,false,null,undefined,Infinity,NaN,isNaN,isFinite,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,parseInt,parseFloat",sr=new RegExp("^("+rr.replace(/,/g,"\\b|")+"\\b)"),or="break,case,class,catch,const,continue,debugger,default,delete,do,else,export,extends,finally,for,function,if,import,in,instanceof,let,return,super,switch,throw,try,var,while,with,yield,enum,await,implements,package,protected,static,interface,private,public",ar=new RegExp("^("+or.replace(/,/g,"\\b|")+"\\b)"),hr=/\s/g,lr=/\n/g,cr=/[\{,]\s*[\w\$_]+\s*:|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*\$\{|\}(?:[^`\\]|\\.)*`|`(?:[^`\\]|\\.)*`)|new |typeof |void /g,ur=/"(\d+)"/g,fr=/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['.*?'\]|\[".*?"\]|\[\d+\]|\[[A-Za-z_$][\w$]*\])*$/,pr=/[^\w$\.](?:[A-Za-z_$][\w$]*)/g,dr=/^(?:true|false|null|undefined|Infinity|NaN)$/,vr=[],mr=Object.freeze({parseExpression:It,isSimplePath:Mt}),gr=[],_r=[],yr={},br={},wr=!1,Cr=0;Ut.prototype.get=function(){this.beforeGet();var t,e=this.scope||this.vm;try{t=this.getter.call(e,e)}catch(i){}return this.deep&&Jt(t),this.preProcess&&(t=this.preProcess(t)),this.filters&&(t=e._applyFilters(t,null,this.filters,!1)),this.postProcess&&(t=this.postProcess(t)),this.afterGet(),t},Ut.prototype.set=function(t){var e=this.scope||this.vm;this.filters&&(t=e._applyFilters(t,this.value,this.filters,!0));try{this.setter.call(e,e,t)}catch(i){}var n=e.$forContext;if(n&&n.alias===this.expression){if(n.filters)return;n._withLock(function(){e.$key?n.rawValue[e.$key]=t:n.rawValue.$set(e.$index,t)})}},Ut.prototype.beforeGet=function(){_t.target=this},Ut.prototype.addDep=function(t){var e=t.id;this.newDepIds.has(e)||(this.newDepIds.add(e),this.newDeps.push(t),this.depIds.has(e)||t.addSub(this))},Ut.prototype.afterGet=function(){_t.target=null;for(var t=this.deps.length;t--;){var e=this.deps[t];this.newDepIds.has(e.id)||e.removeSub(this)}var i=this.depIds;this.depIds=this.newDepIds,this.newDepIds=i,this.newDepIds.clear(),i=this.deps,this.deps=this.newDeps,this.newDeps=i,this.newDeps.length=0},Ut.prototype.update=function(t){this.lazy?this.dirty=!0:this.sync||!An.async?this.run():(this.shallow=this.queued?t?this.shallow:!1:!!t,this.queued=!0,zt(this))},Ut.prototype.run=function(){if(this.active){var t=this.get();if(t!==this.value||(m(t)||this.deep)&&!this.shallow){var e=this.value;this.value=t;this.prevError;this.cb.call(this.vm,t,e)}this.queued=this.shallow=!1}},Ut.prototype.evaluate=function(){var t=_t.target;this.value=this.get(),this.dirty=!1,_t.target=t},Ut.prototype.depend=function(){for(var t=this.deps.length;t--;)this.deps[t].depend()},Ut.prototype.teardown=function(){if(this.active){this.vm._isBeingDestroyed||this.vm._vForRemoving||this.vm._watchers.$remove(this);for(var t=this.deps.length;t--;)this.deps[t].removeSub(this);this.active=!1,this.vm=this.cb=this.value=null}};var $r=new Ki,kr={bind:function(){this.attr=3===this.el.nodeType?"data":"textContent"},update:function(t){this.el[this.attr]=s(t)}},xr=new $(1e3),Ar=new $(1e3),Or={efault:[0,"",""],legend:[1,"<fieldset>","</fieldset>"],tr:[2,"<table><tbody>","</tbody></table>"],col:[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"]};Or.td=Or.th=[3,"<table><tbody><tr>","</tr></tbody></table>"],Or.option=Or.optgroup=[1,'<select multiple="multiple">',"</select>"],Or.thead=Or.tbody=Or.colgroup=Or.caption=Or.tfoot=[1,"<table>","</table>"],Or.g=Or.defs=Or.symbol=Or.use=Or.image=Or.text=Or.circle=Or.ellipse=Or.line=Or.path=Or.polygon=Or.polyline=Or.rect=[1,'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events"version="1.1">',"</svg>"];var Tr=/<([\w:-]+)/,Nr=/&#?\w+?;/,jr=/<!--/,Er=function(){if(Ri){var t=document.createElement("div");return t.innerHTML="<template>1</template>",!t.cloneNode(!0).firstChild.innerHTML}return!1}(),Sr=function(){if(Ri){var t=document.createElement("textarea");return t.placeholder="t","t"===t.cloneNode(!0).value}return!1}(),Fr=Object.freeze({cloneNode:Zt,parseTemplate:Xt}),Dr={bind:function(){8===this.el.nodeType&&(this.nodes=[],this.anchor=nt("v-html"),J(this.el,this.anchor))},update:function(t){t=s(t),this.nodes?this.swap(t):this.el.innerHTML=t},swap:function(t){for(var e=this.nodes.length;e--;)z(this.nodes[e]);var i=Xt(t,!0,!0);this.nodes=d(i.childNodes),B(i,this.anchor)}};Yt.prototype.callHook=function(t){var e,i;for(e=0,i=this.childFrags.length;i>e;e++)this.childFrags[e].callHook(t);for(e=0,i=this.children.length;i>e;e++)t(this.children[e])},Yt.prototype.beforeRemove=function(){var t,e;for(t=0,e=this.childFrags.length;e>t;t++)this.childFrags[t].beforeRemove(!1);for(t=0,e=this.children.length;e>t;t++)this.children[t].$destroy(!1,!0);var i=this.unlink.dirs;for(t=0,e=i.length;e>t;t++)i[t]._watcher&&i[t]._watcher.teardown()},Yt.prototype.destroy=function(){this.parentFrag&&this.parentFrag.childFrags.$remove(this),this.node.__v_frag=null,this.unlink()};var Pr=new $(5e3);se.prototype.create=function(t,e,i){var n=Zt(this.template);return new Yt(this.linker,this.vm,n,t,e,i)};var Rr=700,Lr=800,Hr=850,Ir=1100,Mr=1500,Vr=1500,Br=1750,Wr=2100,zr=2200,Ur=2300,Jr=0,qr={priority:zr,terminal:!0,params:["track-by","stagger","enter-stagger","leave-stagger"],bind:function(){var t=this.expression.match(/(.*) (?:in|of) (.*)/);if(t){var e=t[1].match(/\((.*),(.*)\)/);e?(this.iterator=e[1].trim(),this.alias=e[2].trim()):this.alias=t[1].trim(),this.expression=t[2]}if(this.alias){this.id="__v-for__"+ ++Jr;var i=this.el.tagName;this.isOption=("OPTION"===i||"OPTGROUP"===i)&&"SELECT"===this.el.parentNode.tagName,this.start=nt("v-for-start"),this.end=nt("v-for-end"),J(this.el,this.end),B(this.start,this.end),this.cache=Object.create(null),this.factory=new se(this.vm,this.el)}},update:function(t){this.diff(t),this.updateRef(),this.updateModel()},diff:function(t){var e,n,r,s,o,a,h=t[0],l=this.fromObject=m(h)&&i(h,"$key")&&i(h,"$value"),c=this.params.trackBy,u=this.frags,f=this.frags=new Array(t.length),p=this.alias,d=this.iterator,v=this.start,g=this.end,_=H(v),y=!u;for(e=0,n=t.length;n>e;e++)h=t[e],s=l?h.$key:null,o=l?h.$value:h,a=!m(o),r=!y&&this.getCachedFrag(o,e,s),r?(r.reused=!0,r.scope.$index=e,s&&(r.scope.$key=s),d&&(r.scope[d]=null!==s?s:e),(c||l||a)&&yt(function(){r.scope[p]=o})):(r=this.create(o,p,e,s),r.fresh=!y),f[e]=r,y&&r.before(g);if(!y){var b=0,w=u.length-f.length;for(this.vm._vForRemoving=!0,e=0,n=u.length;n>e;e++)r=u[e],r.reused||(this.deleteCachedFrag(r),this.remove(r,b++,w,_));this.vm._vForRemoving=!1,b&&(this.vm._watchers=this.vm._watchers.filter(function(t){return t.active}));var C,$,k,x=0;for(e=0,n=f.length;n>e;e++)r=f[e],C=f[e-1],$=C?C.staggerCb?C.staggerAnchor:C.end||C.node:v,r.reused&&!r.staggerCb?(k=oe(r,v,this.id),k===C||k&&oe(k,v,this.id)===C||this.move(r,$)):this.insert(r,x++,$,_),r.reused=r.fresh=!1}},create:function(t,e,i,n){var r=this._host,s=this._scope||this.vm,o=Object.create(s);o.$refs=Object.create(s.$refs),o.$els=Object.create(s.$els),o.$parent=s,o.$forContext=this,yt(function(){kt(o,e,t)}),kt(o,"$index",i),n?kt(o,"$key",n):o.$key&&_(o,"$key",null),this.iterator&&kt(o,this.iterator,null!==n?n:i);var a=this.factory.create(r,o,this._frag);return a.forId=this.id,this.cacheFrag(t,a,i,n),a},updateRef:function(){var t=this.descriptor.ref;if(t){var e,i=(this._scope||this.vm).$refs;this.fromObject?(e={},this.frags.forEach(function(t){e[t.scope.$key]=ae(t)})):e=this.frags.map(ae),i[t]=e}},updateModel:function(){if(this.isOption){var t=this.start.parentNode,e=t&&t.__v_model;e&&e.forceUpdate()}},insert:function(t,e,i,n){t.staggerCb&&(t.staggerCb.cancel(),t.staggerCb=null);var r=this.getStagger(t,e,null,"enter");if(n&&r){var s=t.staggerAnchor;s||(s=t.staggerAnchor=nt("stagger-anchor"),s.__v_frag=t),W(s,i);var o=t.staggerCb=w(function(){t.staggerCb=null,t.before(s),z(s)});setTimeout(o,r)}else{var a=i.nextSibling;a||(W(this.end,i),a=this.end),t.before(a)}},remove:function(t,e,i,n){if(t.staggerCb)return t.staggerCb.cancel(),void(t.staggerCb=null);var r=this.getStagger(t,e,i,"leave");if(n&&r){var s=t.staggerCb=w(function(){t.staggerCb=null,t.remove()});setTimeout(s,r)}else t.remove()},move:function(t,e){e.nextSibling||this.end.parentNode.appendChild(this.end),t.before(e.nextSibling,!1)},cacheFrag:function(t,e,n,r){var s,o=this.params.trackBy,a=this.cache,h=!m(t);r||o||h?(s=le(n,r,t,o),a[s]||(a[s]=e)):(s=this.id,i(t,s)?null===t[s]&&(t[s]=e):Object.isExtensible(t)&&_(t,s,e)),e.raw=t},getCachedFrag:function(t,e,i){var n,r=this.params.trackBy,s=!m(t);if(i||r||s){var o=le(e,i,t,r);n=this.cache[o]}else n=t[this.id];return n&&(n.reused||n.fresh),n},deleteCachedFrag:function(t){var e=t.raw,n=this.params.trackBy,r=t.scope,s=r.$index,o=i(r,"$key")&&r.$key,a=!m(e);if(n||o||a){var h=le(s,o,e,n);this.cache[h]=null}else e[this.id]=null,t.raw=null},getStagger:function(t,e,i,n){n+="Stagger";var r=t.node.__v_trans,s=r&&r.hooks,o=s&&(s[n]||s.stagger);return o?o.call(t,e,i):e*parseInt(this.params[n]||this.params.stagger,10)},_preProcess:function(t){return this.rawValue=t,t},_postProcess:function(t){if(Di(t))return t;if(g(t)){for(var e,i=Object.keys(t),n=i.length,r=new Array(n);n--;)e=i[n],r[n]={$key:e,$value:t[e]};return r}return"number"!=typeof t||isNaN(t)||(t=he(t)),t||[]},unbind:function(){if(this.descriptor.ref&&((this._scope||this.vm).$refs[this.descriptor.ref]=null),this.frags)for(var t,e=this.frags.length;e--;)t=this.frags[e],this.deleteCachedFrag(t),t.destroy()}},Qr={priority:Wr,terminal:!0,bind:function(){var t=this.el;if(t.__vue__)this.invalid=!0;else{var e=t.nextElementSibling;e&&null!==I(e,"v-else")&&(z(e),this.elseEl=e),this.anchor=nt("v-if"),J(t,this.anchor)}},update:function(t){this.invalid||(t?this.frag||this.insert():this.remove())},insert:function(){this.elseFrag&&(this.elseFrag.remove(),this.elseFrag=null),this.factory||(this.factory=new se(this.vm,this.el)),this.frag=this.factory.create(this._host,this._scope,this._frag),this.frag.before(this.anchor)},remove:function(){this.frag&&(this.frag.remove(),this.frag=null),this.elseEl&&!this.elseFrag&&(this.elseFactory||(this.elseFactory=new se(this.elseEl._context||this.vm,this.elseEl)),this.elseFrag=this.elseFactory.create(this._host,this._scope,this._frag),this.elseFrag.before(this.anchor))},unbind:function(){this.frag&&this.frag.destroy(),this.elseFrag&&this.elseFrag.destroy()}},Gr={bind:function(){var t=this.el.nextElementSibling;t&&null!==I(t,"v-else")&&(this.elseEl=t)},update:function(t){this.apply(this.el,t),this.elseEl&&this.apply(this.elseEl,!t)},apply:function(t,e){function i(){t.style.display=e?"":"none"}H(t)?R(t,e?1:-1,i,this.vm):i()}},Zr={bind:function(){var t=this,e=this.el,i="range"===e.type,n=this.params.lazy,r=this.params.number,s=this.params.debounce,a=!1;if(Vi||i||(this.on("compositionstart",function(){a=!0}),this.on("compositionend",function(){a=!1,n||t.listener()})),this.focused=!1,i||n||(this.on("focus",function(){t.focused=!0}),this.on("blur",function(){t.focused=!1,t._frag&&!t._frag.inserted||t.rawListener()})),this.listener=this.rawListener=function(){if(!a&&t._bound){var n=r||i?o(e.value):e.value;t.set(n),Yi(function(){t._bound&&!t.focused&&t.update(t._watcher.value)})}},s&&(this.listener=y(this.listener,s)),this.hasjQuery="function"==typeof jQuery,this.hasjQuery){var h=jQuery.fn.on?"on":"bind";jQuery(e)[h]("change",this.rawListener),n||jQuery(e)[h]("input",this.listener)}else this.on("change",this.rawListener),n||this.on("input",this.listener);!n&&Mi&&(this.on("cut",function(){Yi(t.listener)}),this.on("keyup",function(e){46!==e.keyCode&&8!==e.keyCode||t.listener()})),(e.hasAttribute("value")||"TEXTAREA"===e.tagName&&e.value.trim())&&(this.afterBind=this.listener)},update:function(t){t=s(t),t!==this.el.value&&(this.el.value=t)},unbind:function(){var t=this.el;if(this.hasjQuery){var e=jQuery.fn.off?"off":"unbind";jQuery(t)[e]("change",this.listener),jQuery(t)[e]("input",this.listener)}}},Xr={bind:function(){var t=this,e=this.el;this.getValue=function(){if(e.hasOwnProperty("_value"))return e._value;var i=e.value;return t.params.number&&(i=o(i)),i},this.listener=function(){t.set(t.getValue())},this.on("change",this.listener),e.hasAttribute("checked")&&(this.afterBind=this.listener)},update:function(t){this.el.checked=C(t,this.getValue())}},Yr={bind:function(){var t=this,e=this,i=this.el;this.forceUpdate=function(){e._watcher&&e.update(e._watcher.get())};var n=this.multiple=i.hasAttribute("multiple");this.listener=function(){var t=ce(i,n);t=e.params.number?Di(t)?t.map(o):o(t):t,e.set(t)},this.on("change",this.listener);var r=ce(i,n,!0);(n&&r.length||!n&&null!==r)&&(this.afterBind=this.listener),this.vm.$on("hook:attached",function(){Yi(t.forceUpdate)}),H(i)||Yi(this.forceUpdate)},update:function(t){var e=this.el;e.selectedIndex=-1;for(var i,n,r=this.multiple&&Di(t),s=e.options,o=s.length;o--;)i=s[o],n=i.hasOwnProperty("_value")?i._value:i.value,i.selected=r?ue(t,n)>-1:C(t,n)},unbind:function(){this.vm.$off("hook:attached",this.forceUpdate)}},Kr={bind:function(){function t(){var t=i.checked;return t&&i.hasOwnProperty("_trueValue")?i._trueValue:!t&&i.hasOwnProperty("_falseValue")?i._falseValue:t}var e=this,i=this.el;this.getValue=function(){return i.hasOwnProperty("_value")?i._value:e.params.number?o(i.value):i.value},this.listener=function(){var n=e._watcher.value;if(Di(n)){var r=e.getValue();i.checked?b(n,r)<0&&n.push(r):n.$remove(r)}else e.set(t())},this.on("change",this.listener),i.hasAttribute("checked")&&(this.afterBind=this.listener)},update:function(t){var e=this.el;Di(t)?e.checked=b(t,this.getValue())>-1:e.hasOwnProperty("_trueValue")?e.checked=C(t,e._trueValue):e.checked=!!t}},ts={text:Zr,radio:Xr,select:Yr,checkbox:Kr},es={priority:Lr,twoWay:!0,handlers:ts,params:["lazy","number","debounce"],bind:function(){this.checkFilters(),this.hasRead&&!this.hasWrite;var t,e=this.el,i=e.tagName;if("INPUT"===i)t=ts[e.type]||ts.text;else if("SELECT"===i)t=ts.select;else{if("TEXTAREA"!==i)return;t=ts.text}e.__v_model=this,t.bind.call(this),this.update=t.update,this._unbind=t.unbind},checkFilters:function(){var t=this.filters;if(t)for(var e=t.length;e--;){var i=gt(this.vm.$options,"filters",t[e].name);("function"==typeof i||i.read)&&(this.hasRead=!0),i.write&&(this.hasWrite=!0)}},unbind:function(){this.el.__v_model=null,this._unbind&&this._unbind()}},is={esc:27,tab:9,enter:13,space:32,"delete":[8,46],up:38,left:37,right:39,down:40},ns={priority:Rr,acceptStatement:!0,keyCodes:is,bind:function(){if("IFRAME"===this.el.tagName&&"load"!==this.arg){var t=this;this.iframeBind=function(){q(t.el.contentWindow,t.arg,t.handler,t.modifiers.capture)},this.on("load",this.iframeBind)}},update:function(t){if(this.descriptor.raw||(t=function(){}),"function"==typeof t){this.modifiers.stop&&(t=pe(t)),this.modifiers.prevent&&(t=de(t)),this.modifiers.self&&(t=ve(t));var e=Object.keys(this.modifiers).filter(function(t){return"stop"!==t&&"prevent"!==t&&"self"!==t&&"capture"!==t});e.length&&(t=fe(t,e)),this.reset(),this.handler=t,this.iframeBind?this.iframeBind():q(this.el,this.arg,this.handler,this.modifiers.capture)}},reset:function(){var t=this.iframeBind?this.el.contentWindow:this.el;this.handler&&Q(t,this.arg,this.handler)},unbind:function(){this.reset()}},rs=["-webkit-","-moz-","-ms-"],ss=["Webkit","Moz","ms"],os=/!important;?$/,as=Object.create(null),hs=null,ls={deep:!0,update:function(t){"string"==typeof t?this.el.style.cssText=t:Di(t)?this.handleObject(t.reduce(v,{})):this.handleObject(t||{})},handleObject:function(t){var e,i,n=this.cache||(this.cache={});for(e in n)e in t||(this.handleSingle(e,null),delete n[e]);for(e in t)i=t[e],i!==n[e]&&(n[e]=i,this.handleSingle(e,i))},handleSingle:function(t,e){if(t=me(t))if(null!=e&&(e+=""),e){var i=os.test(e)?"important":"";i?(e=e.replace(os,"").trim(),this.el.style.setProperty(t.kebab,e,i)):this.el.style[t.camel]=e}else this.el.style[t.camel]=""}},cs="http://www.w3.org/1999/xlink",us=/^xlink:/,fs=/^v-|^:|^@|^(?:is|transition|transition-mode|debounce|track-by|stagger|enter-stagger|leave-stagger)$/,ps=/^(?:value|checked|selected|muted)$/,ds=/^(?:draggable|contenteditable|spellcheck)$/,vs={value:"_value","true-value":"_trueValue","false-value":"_falseValue"},ms={priority:Hr,bind:function(){var t=this.arg,e=this.el.tagName;t||(this.deep=!0);var i=this.descriptor,n=i.interp;n&&(i.hasOneTime&&(this.expression=j(n,this._scope||this.vm)),(fs.test(t)||"name"===t&&("PARTIAL"===e||"SLOT"===e))&&(this.el.removeAttribute(t),this.invalid=!0))},update:function(t){ +if(!this.invalid){var e=this.arg;this.arg?this.handleSingle(e,t):this.handleObject(t||{})}},handleObject:ls.handleObject,handleSingle:function(t,e){var i=this.el,n=this.descriptor.interp;if(this.modifiers.camel&&(t=l(t)),!n&&ps.test(t)&&t in i){var r="value"===t&&null==e?"":e;i[t]!==r&&(i[t]=r)}var s=vs[t];if(!n&&s){i[s]=e;var o=i.__v_model;o&&o.listener()}return"value"===t&&"TEXTAREA"===i.tagName?void i.removeAttribute(t):void(ds.test(t)?i.setAttribute(t,e?"true":"false"):null!=e&&e!==!1?"class"===t?(i.__v_trans&&(e+=" "+i.__v_trans.id+"-transition"),Z(i,e)):us.test(t)?i.setAttributeNS(cs,t,e===!0?"":e):i.setAttribute(t,e===!0?"":e):i.removeAttribute(t))}},gs={priority:Mr,bind:function(){if(this.arg){var t=this.id=l(this.arg),e=(this._scope||this.vm).$els;i(e,t)?e[t]=this.el:kt(e,t,this.el)}},unbind:function(){var t=(this._scope||this.vm).$els;t[this.id]===this.el&&(t[this.id]=null)}},_s={bind:function(){}},ys={bind:function(){var t=this.el;this.vm.$once("pre-hook:compiled",function(){t.removeAttribute("v-cloak")})}},bs={text:kr,html:Dr,"for":qr,"if":Qr,show:Gr,model:es,on:ns,bind:ms,el:gs,ref:_s,cloak:ys},ws={deep:!0,update:function(t){t?"string"==typeof t?this.setClass(t.trim().split(/\s+/)):this.setClass(_e(t)):this.cleanup()},setClass:function(t){this.cleanup(t);for(var e=0,i=t.length;i>e;e++){var n=t[e];n&&ye(this.el,n,X)}this.prevKeys=t},cleanup:function(t){var e=this.prevKeys;if(e)for(var i=e.length;i--;){var n=e[i];(!t||t.indexOf(n)<0)&&ye(this.el,n,Y)}}},Cs={priority:Vr,params:["keep-alive","transition-mode","inline-template"],bind:function(){this.el.__vue__||(this.keepAlive=this.params.keepAlive,this.keepAlive&&(this.cache={}),this.params.inlineTemplate&&(this.inlineTemplate=K(this.el,!0)),this.pendingComponentCb=this.Component=null,this.pendingRemovals=0,this.pendingRemovalCb=null,this.anchor=nt("v-component"),J(this.el,this.anchor),this.el.removeAttribute("is"),this.el.removeAttribute(":is"),this.descriptor.ref&&this.el.removeAttribute("v-ref:"+u(this.descriptor.ref)),this.literal&&this.setComponent(this.expression))},update:function(t){this.literal||this.setComponent(t)},setComponent:function(t,e){if(this.invalidatePending(),t){var i=this;this.resolveComponent(t,function(){i.mountComponent(e)})}else this.unbuild(!0),this.remove(this.childVM,e),this.childVM=null},resolveComponent:function(t,e){var i=this;this.pendingComponentCb=w(function(n){i.ComponentName=n.options.name||("string"==typeof t?t:null),i.Component=n,e()}),this.vm._resolveComponent(t,this.pendingComponentCb)},mountComponent:function(t){this.unbuild(!0);var e=this,i=this.Component.options.activate,n=this.getCached(),r=this.build();i&&!n?(this.waitingFor=r,be(i,r,function(){e.waitingFor===r&&(e.waitingFor=null,e.transition(r,t))})):(n&&r._updateRef(),this.transition(r,t))},invalidatePending:function(){this.pendingComponentCb&&(this.pendingComponentCb.cancel(),this.pendingComponentCb=null)},build:function(t){var e=this.getCached();if(e)return e;if(this.Component){var i={name:this.ComponentName,el:Zt(this.el),template:this.inlineTemplate,parent:this._host||this.vm,_linkerCachable:!this.inlineTemplate,_ref:this.descriptor.ref,_asComponent:!0,_isRouterView:this._isRouterView,_context:this.vm,_scope:this._scope,_frag:this._frag};t&&v(i,t);var n=new this.Component(i);return this.keepAlive&&(this.cache[this.Component.cid]=n),n}},getCached:function(){return this.keepAlive&&this.cache[this.Component.cid]},unbuild:function(t){this.waitingFor&&(this.keepAlive||this.waitingFor.$destroy(),this.waitingFor=null);var e=this.childVM;return!e||this.keepAlive?void(e&&(e._inactive=!0,e._updateRef(!0))):void e.$destroy(!1,t)},remove:function(t,e){var i=this.keepAlive;if(t){this.pendingRemovals++,this.pendingRemovalCb=e;var n=this;t.$remove(function(){n.pendingRemovals--,i||t._cleanup(),!n.pendingRemovals&&n.pendingRemovalCb&&(n.pendingRemovalCb(),n.pendingRemovalCb=null)})}else e&&e()},transition:function(t,e){var i=this,n=this.childVM;switch(n&&(n._inactive=!0),t._inactive=!1,this.childVM=t,i.params.transitionMode){case"in-out":t.$before(i.anchor,function(){i.remove(n,e)});break;case"out-in":i.remove(n,function(){t.$before(i.anchor,e)});break;default:i.remove(n),t.$before(i.anchor,e)}},unbind:function(){if(this.invalidatePending(),this.unbuild(),this.cache){for(var t in this.cache)this.cache[t].$destroy();this.cache=null}}},$s=An._propBindingModes,ks={},xs=/^[$_a-zA-Z]+[\w$]*$/,As=An._propBindingModes,Os={bind:function(){var t=this.vm,e=t._context,i=this.descriptor.prop,n=i.path,r=i.parentPath,s=i.mode===As.TWO_WAY,o=this.parentWatcher=new Ut(e,r,function(e){xe(t,i,e)},{twoWay:s,filters:i.filters,scope:this._scope});if(ke(t,i,o.value),s){var a=this;t.$once("pre-hook:created",function(){a.childWatcher=new Ut(t,n,function(t){o.set(t)},{sync:!0})})}},unbind:function(){this.parentWatcher.teardown(),this.childWatcher&&this.childWatcher.teardown()}},Ts=[],Ns=!1,js="transition",Es="animation",Ss=Ji+"Duration",Fs=Qi+"Duration",Ds=Ri&&window.requestAnimationFrame,Ps=Ds?function(t){Ds(function(){Ds(t)})}:function(t){setTimeout(t,50)},Rs=Se.prototype;Rs.enter=function(t,e){this.cancelPending(),this.callHook("beforeEnter"),this.cb=e,X(this.el,this.enterClass),t(),this.entered=!1,this.callHookWithCb("enter"),this.entered||(this.cancel=this.hooks&&this.hooks.enterCancelled,je(this.enterNextTick))},Rs.enterNextTick=function(){var t=this;this.justEntered=!0,Ps(function(){t.justEntered=!1});var e=this.enterDone,i=this.getCssTransitionType(this.enterClass);this.pendingJsCb?i===js&&Y(this.el,this.enterClass):i===js?(Y(this.el,this.enterClass),this.setupCssCb(qi,e)):i===Es?this.setupCssCb(Gi,e):e()},Rs.enterDone=function(){this.entered=!0,this.cancel=this.pendingJsCb=null,Y(this.el,this.enterClass),this.callHook("afterEnter"),this.cb&&this.cb()},Rs.leave=function(t,e){this.cancelPending(),this.callHook("beforeLeave"),this.op=t,this.cb=e,X(this.el,this.leaveClass),this.left=!1,this.callHookWithCb("leave"),this.left||(this.cancel=this.hooks&&this.hooks.leaveCancelled,this.op&&!this.pendingJsCb&&(this.justEntered?this.leaveDone():je(this.leaveNextTick)))},Rs.leaveNextTick=function(){var t=this.getCssTransitionType(this.leaveClass);if(t){var e=t===js?qi:Gi;this.setupCssCb(e,this.leaveDone)}else this.leaveDone()},Rs.leaveDone=function(){this.left=!0,this.cancel=this.pendingJsCb=null,this.op(),Y(this.el,this.leaveClass),this.callHook("afterLeave"),this.cb&&this.cb(),this.op=null},Rs.cancelPending=function(){this.op=this.cb=null;var t=!1;this.pendingCssCb&&(t=!0,Q(this.el,this.pendingCssEvent,this.pendingCssCb),this.pendingCssEvent=this.pendingCssCb=null),this.pendingJsCb&&(t=!0,this.pendingJsCb.cancel(),this.pendingJsCb=null),t&&(Y(this.el,this.enterClass),Y(this.el,this.leaveClass)),this.cancel&&(this.cancel.call(this.vm,this.el),this.cancel=null)},Rs.callHook=function(t){this.hooks&&this.hooks[t]&&this.hooks[t].call(this.vm,this.el)},Rs.callHookWithCb=function(t){var e=this.hooks&&this.hooks[t];e&&(e.length>1&&(this.pendingJsCb=w(this[t+"Done"])),e.call(this.vm,this.el,this.pendingJsCb))},Rs.getCssTransitionType=function(t){if(!(!qi||document.hidden||this.hooks&&this.hooks.css===!1||Fe(this.el))){var e=this.type||this.typeCache[t];if(e)return e;var i=this.el.style,n=window.getComputedStyle(this.el),r=i[Ss]||n[Ss];if(r&&"0s"!==r)e=js;else{var s=i[Fs]||n[Fs];s&&"0s"!==s&&(e=Es)}return e&&(this.typeCache[t]=e),e}},Rs.setupCssCb=function(t,e){this.pendingCssEvent=t;var i=this,n=this.el,r=this.pendingCssCb=function(s){s.target===n&&(Q(n,t,r),i.pendingCssEvent=i.pendingCssCb=null,!i.pendingJsCb&&e&&e())};q(n,t,r)};var Ls={priority:Ir,update:function(t,e){var i=this.el,n=gt(this.vm.$options,"transitions",t);t=t||"v",e=e||"v",i.__v_trans=new Se(i,t,n,this.vm),Y(i,e+"-transition"),X(i,t+"-transition")}},Hs={style:ls,"class":ws,component:Cs,prop:Os,transition:Ls},Is=/^v-bind:|^:/,Ms=/^v-on:|^@/,Vs=/^v-([^:]+)(?:$|:(.*)$)/,Bs=/\.[^\.]+/g,Ws=/^(v-bind:|:)?transition$/,zs=1e3,Us=2e3;Ye.terminal=!0;var Js=/[^\w\-:\.]/,qs=Object.freeze({compile:De,compileAndLinkProps:Ie,compileRoot:Me,transclude:si,resolveSlots:li}),Qs=/^v-on:|^@/;di.prototype._bind=function(){var t=this.name,e=this.descriptor;if(("cloak"!==t||this.vm._isCompiled)&&this.el&&this.el.removeAttribute){var i=e.attr||"v-"+t;this.el.removeAttribute(i)}var n=e.def;if("function"==typeof n?this.update=n:v(this,n),this._setupParams(),this.bind&&this.bind(),this._bound=!0,this.literal)this.update&&this.update(e.raw);else if((this.expression||this.modifiers)&&(this.update||this.twoWay)&&!this._checkStatement()){var r=this;this.update?this._update=function(t,e){r._locked||r.update(t,e)}:this._update=pi;var s=this._preProcess?p(this._preProcess,this):null,o=this._postProcess?p(this._postProcess,this):null,a=this._watcher=new Ut(this.vm,this.expression,this._update,{filters:this.filters,twoWay:this.twoWay,deep:this.deep,preProcess:s,postProcess:o,scope:this._scope});this.afterBind?this.afterBind():this.update&&this.update(a.value)}},di.prototype._setupParams=function(){if(this.params){var t=this.params;this.params=Object.create(null);for(var e,i,n,r=t.length;r--;)e=u(t[r]),n=l(e),i=M(this.el,e),null!=i?this._setupParamWatcher(n,i):(i=I(this.el,e),null!=i&&(this.params[n]=""===i?!0:i))}},di.prototype._setupParamWatcher=function(t,e){var i=this,n=!1,r=(this._scope||this.vm).$watch(e,function(e,r){if(i.params[t]=e,n){var s=i.paramWatchers&&i.paramWatchers[t];s&&s.call(i,e,r)}else n=!0},{immediate:!0,user:!1});(this._paramUnwatchFns||(this._paramUnwatchFns=[])).push(r)},di.prototype._checkStatement=function(){var t=this.expression;if(t&&this.acceptStatement&&!Mt(t)){var e=It(t).get,i=this._scope||this.vm,n=function(t){i.$event=t,e.call(i,i),i.$event=null};return this.filters&&(n=i._applyFilters(n,null,this.filters)),this.update(n),!0}},di.prototype.set=function(t){this.twoWay&&this._withLock(function(){this._watcher.set(t)})},di.prototype._withLock=function(t){var e=this;e._locked=!0,t.call(e),Yi(function(){e._locked=!1})},di.prototype.on=function(t,e,i){q(this.el,t,e,i),(this._listeners||(this._listeners=[])).push([t,e])},di.prototype._teardown=function(){if(this._bound){this._bound=!1,this.unbind&&this.unbind(),this._watcher&&this._watcher.teardown();var t,e=this._listeners;if(e)for(t=e.length;t--;)Q(this.el,e[t][0],e[t][1]);var i=this._paramUnwatchFns;if(i)for(t=i.length;t--;)i[t]();this.vm=this.el=this._watcher=this._listeners=null}};var Gs=/[^|]\|[^|]/;xt(wi),ui(wi),fi(wi),vi(wi),mi(wi),gi(wi),_i(wi),yi(wi),bi(wi);var Zs={priority:Ur,params:["name"],bind:function(){var t=this.params.name||"default",e=this.vm._slotContents&&this.vm._slotContents[t];e&&e.hasChildNodes()?this.compile(e.cloneNode(!0),this.vm._context,this.vm):this.fallback()},compile:function(t,e,i){if(t&&e){if(this.el.hasChildNodes()&&1===t.childNodes.length&&1===t.childNodes[0].nodeType&&t.childNodes[0].hasAttribute("v-if")){var n=document.createElement("template");n.setAttribute("v-else",""),n.innerHTML=this.el.innerHTML,n._context=this.vm,t.appendChild(n)}var r=i?i._scope:this._scope;this.unlink=e.$compile(t,i,r,this._frag)}t?J(this.el,t):z(this.el)},fallback:function(){this.compile(K(this.el,!0),this.vm)},unbind:function(){this.unlink&&this.unlink()}},Xs={priority:Br,params:["name"],paramWatchers:{name:function(t){Qr.remove.call(this),t&&this.insert(t)}},bind:function(){this.anchor=nt("v-partial"),J(this.el,this.anchor),this.insert(this.params.name)},insert:function(t){var e=gt(this.vm.$options,"partials",t,!0);e&&(this.factory=new se(this.vm,e),Qr.insert.call(this))},unbind:function(){this.frag&&this.frag.destroy()}},Ys={slot:Zs,partial:Xs},Ks=qr._postProcess,to=/(\d{3})(?=\d)/g,eo={orderBy:ki,filterBy:$i,limitBy:Ci,json:{read:function(t,e){return"string"==typeof t?t:JSON.stringify(t,null,arguments.length>1?e:2)},write:function(t){try{return JSON.parse(t)}catch(e){return t}}},capitalize:function(t){return t||0===t?(t=t.toString(),t.charAt(0).toUpperCase()+t.slice(1)):""},uppercase:function(t){return t||0===t?t.toString().toUpperCase():""},lowercase:function(t){return t||0===t?t.toString().toLowerCase():""},currency:function(t,e,i){if(t=parseFloat(t),!isFinite(t)||!t&&0!==t)return"";e=null!=e?e:"$",i=null!=i?i:2;var n=Math.abs(t).toFixed(i),r=i?n.slice(0,-1-i):n,s=r.length%3,o=s>0?r.slice(0,s)+(r.length>3?",":""):"",a=i?n.slice(-1-i):"",h=0>t?"-":"";return h+e+o+r.slice(s).replace(to,"$1,")+a},pluralize:function(t){var e=d(arguments,1),i=e.length;if(i>1){var n=t%10-1;return n in e?e[n]:e[i-1]}return e[0]+(1===t?"":"s")},debounce:function(t,e){return t?(e||(e=300),y(t,e)):void 0}};return Ai(wi),wi.version="1.0.26",setTimeout(function(){An.devtools&&Li&&Li.emit("init",wi)},0),wi}); +//# sourceMappingURL=vue.min.js.map
\ No newline at end of file diff --git a/vendor/gitignore/Erlang.gitignore b/vendor/gitignore/Erlang.gitignore index 8e46d5a07f8..3826c85736f 100644 --- a/vendor/gitignore/Erlang.gitignore +++ b/vendor/gitignore/Erlang.gitignore @@ -4,7 +4,7 @@ deps *.beam *.plt erl_crash.dump -ebin +ebin/*.beam rel/example_project .concrete/DEV_MODE .rebar diff --git a/vendor/gitignore/Global/Ansible.gitignore b/vendor/gitignore/Global/Ansible.gitignore new file mode 100644 index 00000000000..a8b42eb6eed --- /dev/null +++ b/vendor/gitignore/Global/Ansible.gitignore @@ -0,0 +1 @@ +*.retry diff --git a/vendor/gitignore/Global/Linux.gitignore b/vendor/gitignore/Global/Linux.gitignore index cc9586893b6..b56bf65d855 100644 --- a/vendor/gitignore/Global/Linux.gitignore +++ b/vendor/gitignore/Global/Linux.gitignore @@ -8,3 +8,6 @@ # Linux trash folder which might appear on any partition or disk .Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* diff --git a/vendor/gitignore/Global/NetBeans.gitignore b/vendor/gitignore/Global/NetBeans.gitignore index 520d91ff584..254108cd23b 100644 --- a/vendor/gitignore/Global/NetBeans.gitignore +++ b/vendor/gitignore/Global/NetBeans.gitignore @@ -3,5 +3,4 @@ build/ nbbuild/ dist/ nbdist/ -nbactions.xml .nb-gradle/ diff --git a/vendor/gitignore/Global/Tags.gitignore b/vendor/gitignore/Global/Tags.gitignore index c0318165a27..91927af4cd6 100644 --- a/vendor/gitignore/Global/Tags.gitignore +++ b/vendor/gitignore/Global/Tags.gitignore @@ -9,6 +9,7 @@ gtags.files GTAGS GRTAGS GPATH +GSYMS cscope.files cscope.out cscope.in.out diff --git a/vendor/gitignore/Global/VisualStudioCode.gitignore b/vendor/gitignore/Global/VisualStudioCode.gitignore index faa18382a3c..d9960081c98 100644 --- a/vendor/gitignore/Global/VisualStudioCode.gitignore +++ b/vendor/gitignore/Global/VisualStudioCode.gitignore @@ -1,2 +1,4 @@ -.vscode - +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json diff --git a/vendor/gitignore/Global/OSX.gitignore b/vendor/gitignore/Global/macOS.gitignore index 5972fe50f66..828a509a137 100644 --- a/vendor/gitignore/Global/OSX.gitignore +++ b/vendor/gitignore/Global/macOS.gitignore @@ -3,7 +3,8 @@ .LSOverride # Icon must end with two \r -Icon
+Icon + # Thumbnails ._* diff --git a/vendor/gitignore/Go.gitignore b/vendor/gitignore/Go.gitignore index cd0d5d1e2f4..397a0ed4acb 100644 --- a/vendor/gitignore/Go.gitignore +++ b/vendor/gitignore/Go.gitignore @@ -25,3 +25,6 @@ _testmain.go # Output of the go coverage tool, specifically when used with LiteIDE *.out + +# external packages folder +vendor/ diff --git a/vendor/gitignore/Haskell.gitignore b/vendor/gitignore/Haskell.gitignore index a4ee41ab62b..450f32ec40c 100644 --- a/vendor/gitignore/Haskell.gitignore +++ b/vendor/gitignore/Haskell.gitignore @@ -17,3 +17,4 @@ cabal.sandbox.config *.eventlog .stack-work/ cabal.project.local +.HTF/ diff --git a/vendor/gitignore/Joomla.gitignore b/vendor/gitignore/Joomla.gitignore index 0d7a0de298f..93103fdbe77 100644 --- a/vendor/gitignore/Joomla.gitignore +++ b/vendor/gitignore/Joomla.gitignore @@ -52,6 +52,7 @@ /administrator/language/en-GB/en-GB.plg_content_contact.sys.ini /administrator/language/en-GB/en-GB.plg_content_finder.ini /administrator/language/en-GB/en-GB.plg_content_finder.sys.ini +/administrator/language/en-GB/en-GB.plg_editors-xtd_module* /administrator/language/en-GB/en-GB.plg_finder_categories.ini /administrator/language/en-GB/en-GB.plg_finder_categories.sys.ini /administrator/language/en-GB/en-GB.plg_finder_contacts.ini @@ -64,6 +65,10 @@ /administrator/language/en-GB/en-GB.plg_finder_tags.sys.ini /administrator/language/en-GB/en-GB.plg_finder_weblinks.ini /administrator/language/en-GB/en-GB.plg_finder_weblinks.sys.ini +/administrator/language/en-GB/en-GB.plg_installer_folderinstaller* +/administrator/language/en-GB/en-GB.plg_installer_packageinstaller* +/administrator/language/en-GB/en-GB.plg_installer_packageinstaller +/administrator/language/en-GB/en-GB.plg_installer_urlinstaller* /administrator/language/en-GB/en-GB.plg_installer_webinstaller.ini /administrator/language/en-GB/en-GB.plg_installer_webinstaller.sys.ini /administrator/language/en-GB/en-GB.plg_quickicon_joomlaupdate.ini @@ -72,6 +77,8 @@ /administrator/language/en-GB/en-GB.plg_search_tags.sys.ini /administrator/language/en-GB/en-GB.plg_system_languagecode.ini /administrator/language/en-GB/en-GB.plg_system_languagecode.sys.ini +/administrator/language/en-GB/en-GB.plg_system_stats* +/administrator/language/en-GB/en-GB.plg_system_updatenotification* /administrator/language/en-GB/en-GB.plg_twofactorauth_totp.ini /administrator/language/en-GB/en-GB.plg_twofactorauth_totp.sys.ini /administrator/language/en-GB/en-GB.plg_twofactorauth_yubikey.ini @@ -249,8 +256,10 @@ /administrator/language/en-GB/en-GB.tpl_hathor.sys.ini /administrator/language/en-GB/en-GB.xml /administrator/language/en-GB/index.html +/administrator/language/ru-RU/index.html /administrator/language/overrides/* /administrator/language/index.html +/administrator/logs/index.html /administrator/manifests/* /administrator/modules/mod_custom/* /administrator/modules/mod_feed/* @@ -289,6 +298,7 @@ /components/com_finder/* /components/com_mailto/* /components/com_media/* +/components/com_modules/* /components/com_newsfeeds/* /components/com_search/* /components/com_users/* @@ -407,6 +417,7 @@ /libraries/idna_convert/* /libraries/joomla/* /libraries/legacy/* +/libraries/php-encryption/* /libraries/phpass/* /libraries/phpmailer/* /libraries/phputf8/* @@ -431,9 +442,11 @@ /media/media/* /media/mod_languages/* /media/overrider/* +/media/plg_captcha_recaptcha/* /media/plg_quickicon_extensionupdate/* /media/plg_quickicon_joomlaupdate/* /media/plg_system_highlight/* +/media/plg_system_stats/* /media/system/* /media/index.html /modules/mod_articles_archive/* @@ -486,6 +499,7 @@ /plugins/editors/none/* /plugins/editors/tinymce/* /plugins/editors/index.html +/plugins/editors-xtd/module/* /plugins/editors-xtd/article/* /plugins/editors-xtd/image/* /plugins/editors-xtd/pagebreak/* @@ -523,6 +537,8 @@ /plugins/system/redirect/* /plugins/system/remember/* /plugins/system/sef/* +/plugins/system/stats/* +/plugins/system/updatenotification/* /plugins/system/index.html /plugins/twofactorauth/* /plugins/user/contactcreator/* diff --git a/vendor/gitignore/Node.gitignore b/vendor/gitignore/Node.gitignore index aea5294de9d..bc7fc55724c 100644 --- a/vendor/gitignore/Node.gitignore +++ b/vendor/gitignore/Node.gitignore @@ -34,5 +34,11 @@ jspm_packages # Optional npm cache directory .npm +# Optional eslint cache +.eslintcache + # Optional REPL history .node_repl_history + +# Output of 'npm pack' +*.tgz diff --git a/vendor/gitignore/Objective-C.gitignore b/vendor/gitignore/Objective-C.gitignore index 20592083931..58c51ecaed4 100644 --- a/vendor/gitignore/Objective-C.gitignore +++ b/vendor/gitignore/Objective-C.gitignore @@ -50,7 +50,9 @@ Carthage/Build # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md fastlane/report.xml +fastlane/Preview.html fastlane/screenshots +fastlane/test_output # Code Injection # diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore index 72364f99fe4..37fc9d40817 100644 --- a/vendor/gitignore/Python.gitignore +++ b/vendor/gitignore/Python.gitignore @@ -79,6 +79,7 @@ celerybeat-schedule .env # virtualenv +.venv/ venv/ ENV/ diff --git a/vendor/gitignore/Rails.gitignore b/vendor/gitignore/Rails.gitignore index d8c256c1925..e97427608c1 100644 --- a/vendor/gitignore/Rails.gitignore +++ b/vendor/gitignore/Rails.gitignore @@ -12,9 +12,11 @@ capybara-*.html rerun.txt pickle-email-*.html -# TODO Comment out these rules if you are OK with secrets being uploaded to the repo +# TODO Comment out this rule if you are OK with secrets being uploaded to the repo config/initializers/secret_token.rb -config/secrets.yml + +# Only include if you have production secrets in this file, which is no longer a Rails default +# config/secrets.yml # dotenv # TODO Comment out this rule if environment variables can be committed diff --git a/vendor/gitignore/TeX.gitignore b/vendor/gitignore/TeX.gitignore index 34f999df3e7..f620fad23eb 100644 --- a/vendor/gitignore/TeX.gitignore +++ b/vendor/gitignore/TeX.gitignore @@ -192,3 +192,6 @@ TSWLatexianTemp* # KBibTeX *~[0-9]* + +# auto folder when using emacs and auctex +/auto/* diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore index 67acbf42f5e..1b86e7ec918 100644 --- a/vendor/gitignore/VisualStudio.gitignore +++ b/vendor/gitignore/VisualStudio.gitignore @@ -110,6 +110,10 @@ _TeamCity* # DotCover is a Code Coverage Tool *.dotCover +# Visual Studio code coverage results +*.coverage +*.coveragexml + # NCrunch _NCrunch_* .*crunch*.local.xml @@ -189,6 +193,7 @@ ClientBin/ *~ *.dbmdl *.dbproj.schemaview +*.jfm *.pfx *.publishsettings node_modules/ @@ -251,3 +256,13 @@ paket-files/ # JetBrains Rider .idea/ *.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/ diff --git a/vendor/gitlab-ci-yml/.gitlab-ci.yml b/vendor/gitlab-ci-yml/.gitlab-ci.yml new file mode 100644 index 00000000000..18b14554887 --- /dev/null +++ b/vendor/gitlab-ci-yml/.gitlab-ci.yml @@ -0,0 +1,4 @@ +image: ruby:2.3-alpine + +test: + script: ruby verify_templates.rb diff --git a/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml b/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml index 396d3f1b042..f3fa3949656 100644 --- a/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml @@ -1,7 +1,12 @@ # Official docker image. image: docker:latest +services: + - docker:dind + build: stage: build script: - - docker build -t test . + - 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" diff --git a/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml b/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml new file mode 100644 index 00000000000..263c4c19999 --- /dev/null +++ b/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml @@ -0,0 +1,34 @@ +# This template uses the java:8 docker image because there isn't any +# official Gradle image at this moment +# +# This is the Gradle build system for JVM applications +# https://gradle.org/ +# https://github.com/gradle/gradle +image: java:8 + +# Make the gradle wrapper executable. This essentially downloads a copy of +# Gradle to build the project with. +# https://docs.gradle.org/current/userguide/gradle_wrapper.html +# It is expected that any modern gradle project has a wrapper +before_script: + - chmod +x gradlew + +# We redirect the gradle user home using -g so that it caches the +# wrapper and dependencies. +# https://docs.gradle.org/current/userguide/gradle_command_line.html +# +# Unfortunately it also caches the build output so +# cleaning removes reminants of any cached builds. +# The assemble task actually builds the project. +# If it fails here, the tests can't run. +build: + stage: build + script: + - ./gradlew -g /cache/.gradle clean assemble + allow_failure: false + +# Use the generated build output to run the tests. +test: + stage: test + script: + - ./gradlew -g /cache./gradle check diff --git a/vendor/gitlab-ci-yml/Julia.gitlab-ci.yml b/vendor/gitlab-ci-yml/Julia.gitlab-ci.yml new file mode 100644 index 00000000000..140cb4635f3 --- /dev/null +++ b/vendor/gitlab-ci-yml/Julia.gitlab-ci.yml @@ -0,0 +1,54 @@ +# An example .gitlab-ci.yml file to test (and optionally report the coverage +# results of) your [Julia][1] packages. Please refer to the [documentation][2] +# for more information about package development in Julia. +# +# Here, it is assumed that your Julia package is named `MyPackage`. Change it to +# whatever name you have given to your package. +# +# [1]: http://julialang.org/ +# [2]: http://julia.readthedocs.org/ + +# Below is the template to run your tests in Julia +.test_template: &test_definition + # Uncomment below if you would like to run the tests on specific references + # only, such as the branches `master`, `development`, etc. + # only: + # - master + # - development + script: + # Let's run the tests. Substitute `coverage = false` below, if you do not + # want coverage results. + - /opt/julia/bin/julia -e 'Pkg.clone(pwd()); Pkg.test("MyPackage", + coverage = true)' + # Comment out below if you do not want coverage results. + - /opt/julia/bin/julia -e 'Pkg.add("Coverage"); cd(Pkg.dir("MyPackage")); + using Coverage; cl, tl = get_summary(process_folder()); + println("(", cl/tl*100, "%) covered")' + +# Name a test and select an appropriate image. +test:0.4.6: + image: julialang/julia:v0.4.6 + <<: *test_definition + +# Maybe you would like to test your package against the development branch: +test:0.5.0-dev: + image: julialang/julia:v0.5.0-dev + # ... allowing for failures, since we are testing against the development + # branch: + allow_failure: true + <<: *test_definition + +# REMARK: Do not forget to enable the coverage feature for your project, if you +# are using code coverage reporting above. This can be done by +# +# - Navigating to the `CI/CD Pipelines` settings of your project, +# - Copying and pasting the default `Simplecov` regex example provided, i.e., +# `\(\d+.\d+\%\) covered` in the `test coverage parsing` textfield. +# +# WARNING: This template is using the `julialang/julia` images from [Docker +# Hub][3]. One can use custom Julia images and/or the official ones found +# in the same place. However, care must be taken to correctly locate the binary +# file (`/opt/julia/bin/julia` above), which is usually given on the image's +# description page. +# +# [3]: http://hub.docker.com/ diff --git a/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml b/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml index b468d79bcad..908463c9d12 100644 --- a/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml @@ -1,25 +1,17 @@ # Full project: https://gitlab.com/pages/hexo -image: python:2.7 - -cache: - paths: - - vendor/ - -test: - stage: test - script: - - pip install hyde - - hyde gen - except: - - master +image: node:4.2.2 pages: - stage: deploy + cache: + paths: + - node_modules/ + script: - - pip install hyde - - hyde gen -d public + - npm install hexo-cli -g + - npm install + - hexo deploy artifacts: paths: - public only: - - master + - master diff --git a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml index 16a685ee03d..08b57c8c0ac 100644 --- a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml @@ -10,6 +10,9 @@ services: - redis:latest - postgres:latest +variables: + POSTGRES_DB: database_name + # Cache gems in between builds cache: paths: @@ -34,6 +37,18 @@ rspec: - rspec spec rails: + variables: + DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/$POSTGRES_DB" script: - bundle exec rake db:migrate + - bundle exec rake db:seed - bundle exec rake test + +# This deploy job uses a simple deploy flow to Heroku, other providers, e.g. AWS Elastic Beanstalk +# are supported too: https://github.com/travis-ci/dpl +deploy: + type: deploy + environment: production + script: + - gem install dpl + - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_PRODUCTION_KEY diff --git a/vendor/gitlab-ci-yml/Swift.gitlab-ci.yml b/vendor/gitlab-ci-yml/Swift.gitlab-ci.yml new file mode 100644 index 00000000000..c9c35906d1c --- /dev/null +++ b/vendor/gitlab-ci-yml/Swift.gitlab-ci.yml @@ -0,0 +1,30 @@ +# Lifted from: https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/ +# This file assumes an own GitLab CI runner, setup on an OS X system. +stages: + - build + - archive + +build_project: + stage: build + script: + - xcodebuild clean -project ProjectName.xcodeproj -scheme SchemeName | xcpretty + - xcodebuild test -project ProjectName.xcodeproj -scheme SchemeName -destination 'platform=iOS Simulator,name=iPhone 6s,OS=9.2' | xcpretty -s + tags: + - ios_9-2 + - xcode_7-2 + - osx_10-11 + +archive_project: + stage: archive + script: + - xcodebuild clean archive -archivePath build/ProjectName -scheme SchemeName + - xcodebuild -exportArchive -exportFormat ipa -archivePath "build/ProjectName.xcarchive" -exportPath "build/ProjectName.ipa" -exportProvisioningProfile "ProvisioningProfileName" + only: + - master + artifacts: + paths: + - build/ProjectName.ipa + tags: + - ios_9-2 + - xcode_7-2 + - osx_10-11 |